Files
trail-into-darkness/Packages/com.jovian.encounter-system/Editor/EncounterBrowserWindow.cs
2026-04-19 18:34:22 +02:00

674 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Browser for every encounter across all tables. Search + kind filter + detail pane. Quest chains render as a tree rooted at the first step.</summary>
public class EncounterBrowserWindow : EditorWindow {
private const string AllKinds = "All";
private class Record {
public EncounterTable table;
public int index;
public IEncounter encounter;
public int depth;
public bool IsTableHeader => encounter == null;
}
private static readonly Color[] DepthColors = {
new(0.35f, 0.55f, 0.85f), // depth 0 — table headers (blue)
new(0.90f, 0.90f, 0.90f), // depth 1 — encounters under table (neutral)
new(0.70f, 0.90f, 0.55f), // depth 2 — chain step 1 (green)
new(0.95f, 0.80f, 0.45f), // depth 3 — chain step 2 (amber)
new(0.85f, 0.65f, 0.90f) // depth 4+ — deeper chain (violet)
};
private readonly List<Record> allRecords = new();
private string searchText = string.Empty;
private string kindFilter = AllKinds;
private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new();
private TreeView treeView;
private VisualElement detailPane;
private ToolbarMenu kindDropdown;
private VisualElement statusBanner;
[MenuItem("Jovian/Encounters/Encounter Browser")]
public static void Open() {
var window = GetWindow<EncounterBrowserWindow>("Encounters");
window.minSize = new Vector2(640, 360);
}
private void CreateGUI() {
BuildToolbar();
BuildSplit();
Refresh();
}
private void BuildToolbar() {
var toolbar = new Toolbar();
var search = new ToolbarSearchField();
search.style.flexGrow = 1f;
search.RegisterValueChangedCallback(evt => {
searchText = evt.newValue ?? string.Empty;
ApplyFilter();
});
toolbar.Add(search);
kindDropdown = new ToolbarMenu { text = $"Kind: {AllKinds}" };
foreach(var choice in GetKindChoices()) {
var captured = choice;
kindDropdown.menu.AppendAction(captured, _ => {
kindFilter = captured;
kindDropdown.text = $"Kind: {captured}";
ApplyFilter();
});
}
toolbar.Add(kindDropdown);
var refreshButton = new ToolbarButton(Refresh) { text = "Refresh" };
toolbar.Add(refreshButton);
rootVisualElement.Add(toolbar);
}
private void BuildSplit() {
statusBanner = new VisualElement {
style = {
paddingLeft = 10,
paddingRight = 10,
paddingTop = 8,
paddingBottom = 8,
marginBottom = 2,
flexDirection = FlexDirection.Column,
backgroundColor = new StyleColor(new Color(0.85f, 0.4f, 0.15f, 0.35f)),
display = DisplayStyle.None
}
};
rootVisualElement.Add(statusBanner);
var split = new VisualElement {
style = {
flexDirection = FlexDirection.Row,
flexGrow = 1f
}
};
rootVisualElement.Add(split);
treeView = new TreeView {
makeItem = MakeRow,
bindItem = BindRow,
fixedItemHeight = 22,
selectionType = SelectionType.Single
};
treeView.selectionChanged += OnSelectionChanged;
treeView.style.width = 280;
treeView.style.flexShrink = 0f;
treeView.style.borderRightWidth = 1;
treeView.style.borderRightColor = new StyleColor(new Color(0f, 0f, 0f, 0.4f));
split.Add(treeView);
detailPane = new ScrollView(ScrollViewMode.Vertical) {
style = { paddingLeft = 8, paddingTop = 8, paddingRight = 8, flexGrow = 1f }
};
ShowEmptyDetail();
split.Add(detailPane);
}
private static VisualElement MakeRow() {
var row = new VisualElement {
style = {
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
paddingLeft = 6,
paddingRight = 6,
height = 22,
flexGrow = 1f,
flexShrink = 0f
}
};
var badge = new VisualElement {
name = "issue-badge",
style = {
width = 8,
height = 8,
marginRight = 6,
borderTopLeftRadius = 4,
borderTopRightRadius = 4,
borderBottomLeftRadius = 4,
borderBottomRightRadius = 4,
visibility = Visibility.Hidden
}
};
row.Add(badge);
var label = new Label {
name = "row-label",
style = {
flexGrow = 1f,
unityTextAlign = TextAnchor.MiddleLeft
}
};
row.Add(label);
var selectButton = new Button {
name = "select-button",
text = "Select",
style = {
marginLeft = 4,
marginRight = 0,
paddingLeft = 6,
paddingRight = 6,
display = DisplayStyle.None
}
};
row.Add(selectButton);
return row;
}
private void BindRow(VisualElement element, int index) {
var record = treeView.GetItemDataForIndex<Record>(index);
var label = element.Q<Label>("row-label");
var badge = element.Q<VisualElement>("issue-badge");
var selectButton = element.Q<Button>("select-button");
var depthIndex = Mathf.Clamp(record.depth, 0, DepthColors.Length - 1);
label.style.color = new StyleColor(DepthColors[depthIndex]);
if(record.IsTableHeader) {
label.text = record.table != null ? record.table.name : "<missing table>";
label.style.unityFontStyleAndWeight = FontStyle.Bold;
badge.style.visibility = Visibility.Hidden;
element.tooltip = $"EncounterTable asset: {record.table?.name}";
selectButton.style.display = DisplayStyle.Flex;
var table = record.table;
selectButton.clickable = new Clickable(() => {
if(table == null) {
return;
}
Selection.activeObject = table;
EditorGUIUtility.PingObject(table);
});
return;
}
label.style.unityFontStyleAndWeight = FontStyle.Normal;
selectButton.style.display = DisplayStyle.None;
var name = record.encounter?.EncounterDefinition?.name;
var kind = record.encounter?.EncounterDefinition?.Kind?.GetType().Name ?? "—";
label.text = string.IsNullOrEmpty(name)
? $"<unnamed> [{kind}]"
: $"{name} [{kind}]";
if(record.encounter != null && issuesByEncounter.TryGetValue(record.encounter, out var issues) && issues.Count > 0) {
var hasError = issues.Exists(i => i.severity == ValidationSeverity.Error);
badge.style.backgroundColor = new StyleColor(hasError ? new Color(0.85f, 0.25f, 0.25f) : new Color(0.95f, 0.75f, 0.1f));
badge.style.visibility = Visibility.Visible;
element.tooltip = BuildTooltip(issues);
}
else {
badge.style.visibility = Visibility.Hidden;
element.tooltip = string.Empty;
}
}
private static string BuildTooltip(List<ValidationIssue> issues) {
var lines = new List<string>(issues.Count);
foreach(var issue in issues) {
var prefix = issue.severity == ValidationSeverity.Error ? "ERROR" : "WARN";
lines.Add($"[{prefix}] {issue.path} — {issue.message}");
}
return string.Join("\n", lines);
}
private static IEnumerable<string> GetKindChoices() {
yield return AllKinds;
var kindTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => {
try { return assembly.GetTypes(); }
catch { return Array.Empty<Type>(); }
})
.Where(type => typeof(IEncounterKind).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
.Select(type => type.Name)
.OrderBy(name => name);
foreach(var name in kindTypes) {
yield return name;
}
}
private void Refresh() {
allRecords.Clear();
var registries = FindAssetsOfType<EncounterRegistry>().ToList();
UpdateStatusBanner(registries);
var seenTables = new HashSet<EncounterTable>();
foreach(var registry in registries) {
if(registry?.encounterCollections == null) {
continue;
}
foreach(var collection in registry.encounterCollections) {
if(collection?.encounterTables == null) {
continue;
}
foreach(var table in collection.encounterTables) {
if(table?.encounters == null || !seenTables.Add(table)) {
continue;
}
for(var i = 0; i < table.encounters.Count; i++) {
allRecords.Add(new Record {
table = table,
index = i,
encounter = table.encounters[i]
});
}
}
}
}
RebuildIssueIndex();
ApplyFilter();
}
private void UpdateStatusBanner(List<EncounterRegistry> registries) {
statusBanner.Clear();
if(registries.Count == 0) {
statusBanner.style.display = DisplayStyle.Flex;
statusBanner.Add(new Label("No EncounterRegistry asset found. The browser reads encounters from the registry — without one, it has nothing to show.") {
style = { marginBottom = 6, whiteSpace = WhiteSpace.Normal }
});
statusBanner.Add(new Button(CreateRegistryInteractive) {
text = "Create Registry…",
style = { alignSelf = Align.FlexStart }
});
return;
}
var emptyRegistries = registries.FindAll(r => r?.encounterCollections == null || r.encounterCollections.Length == 0);
if(emptyRegistries.Count == registries.Count) {
statusBanner.style.display = DisplayStyle.Flex;
statusBanner.Add(new Label("Registry found but no EncountersCollection is assigned to its 'encounterCollections' array.") {
style = { whiteSpace = WhiteSpace.Normal }
});
var pingButton = new Button(() => {
Selection.activeObject = registries[0];
EditorGUIUtility.PingObject(registries[0]);
}) {
text = "Open Registry",
style = { alignSelf = Align.FlexStart, marginTop = 6 }
};
statusBanner.Add(pingButton);
return;
}
statusBanner.style.display = DisplayStyle.None;
}
private void CreateRegistryInteractive() {
var path = EditorUtility.SaveFilePanelInProject(
"Create Encounter Registry",
"EncounterRegistry",
"asset",
"Choose where to save the new EncounterRegistry asset.");
if(string.IsNullOrEmpty(path)) {
return;
}
var registry = ScriptableObject.CreateInstance<EncounterRegistry>();
AssetDatabase.CreateAsset(registry, path);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Selection.activeObject = registry;
EditorGUIUtility.PingObject(registry);
Refresh();
}
private static IEnumerable<T> FindAssetsOfType<T>() where T : UnityEngine.Object {
var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);
foreach(var guid in guids) {
var path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
if(asset != null) {
yield return asset;
}
}
}
private void RebuildIssueIndex() {
issuesByEncounter.Clear();
var issues = EncounterValidator.ValidateProject();
foreach(var issue in issues) {
if(issue.encounter == null) {
continue;
}
if(!issuesByEncounter.TryGetValue(issue.encounter, out var list)) {
list = new List<ValidationIssue>();
issuesByEncounter[issue.encounter] = list;
}
list.Add(issue);
}
}
private void ApplyFilter() {
var filtered = allRecords.Where(Matches).ToList();
var items = BuildTreeItems(filtered);
if(treeView == null) {
return;
}
treeView.ClearSelection();
treeView.SetRootItems(items);
treeView.Rebuild();
// Expand after layout settles — ExpandAll immediately after Rebuild can no-op in some cases.
treeView.schedule.Execute(() => treeView.ExpandAll()).ExecuteLater(0);
ShowEmptyDetail();
}
private static List<TreeViewItemData<Record>> BuildTreeItems(List<Record> records) {
// Group by table: each table becomes a top-level row, its encounters nest under it.
var byTable = new Dictionary<EncounterTable, List<Record>>();
var tableOrder = new List<EncounterTable>();
foreach(var r in records) {
if(r.table == null) {
continue;
}
if(!byTable.TryGetValue(r.table, out var list)) {
list = new List<Record>();
byTable[r.table] = list;
tableOrder.Add(r.table);
}
list.Add(r);
}
var result = new List<TreeViewItemData<Record>>();
var uid = 0;
foreach(var table in tableOrder) {
var tableRecord = new Record { table = table, index = -1, encounter = null, depth = 0 };
var tableId = uid++;
var children = BuildEncounterNodes(byTable[table], 1, ref uid);
result.Add(new TreeViewItemData<Record>(tableId, tableRecord, children));
}
return result;
}
private static List<TreeViewItemData<Record>> BuildEncounterNodes(List<Record> records, int depth, ref int uid) {
var byEncounter = new Dictionary<IEncounter, Record>();
foreach(var r in records) {
if(r.encounter != null) {
byEncounter[r.encounter] = r;
}
}
// Chain nesting only applies to chains contained within this table. Cross-table chains
// naturally appear flat (the target encounter lives under its own table header).
var predecessorCount = new Dictionary<IEncounter, int>();
foreach(var r in records) {
if(r.encounter?.EncounterDefinition?.Kind is not QuestKind quest) {
continue;
}
var next = quest.nextEncounter.Resolve();
if(next != null && byEncounter.ContainsKey(next)) {
predecessorCount.TryGetValue(next, out var count);
predecessorCount[next] = count + 1;
}
}
var parentOf = new Dictionary<IEncounter, IEncounter>();
foreach(var r in records) {
if(r.encounter?.EncounterDefinition?.Kind is not QuestKind quest) {
continue;
}
var next = quest.nextEncounter.Resolve();
if(next == null || !byEncounter.ContainsKey(next)) {
continue;
}
if(predecessorCount[next] != 1) {
continue;
}
if(ForwardReaches(next, r.encounter, byEncounter)) {
continue;
}
parentOf[next] = r.encounter;
}
var nodes = new List<TreeViewItemData<Record>>();
foreach(var r in records) {
if(r.encounter != null && parentOf.ContainsKey(r.encounter)) {
continue;
}
r.depth = depth;
var rootId = uid++;
var children = BuildChainChildren(r, byEncounter, parentOf, depth + 1, ref uid);
nodes.Add(new TreeViewItemData<Record>(rootId, r, children));
}
return nodes;
}
private static bool ForwardReaches(IEncounter start, IEncounter target, Dictionary<IEncounter, Record> byEncounter) {
var current = start;
var visited = new HashSet<IEncounter>();
while(current?.EncounterDefinition?.Kind is QuestKind quest) {
var next = quest.nextEncounter.Resolve();
if(next == null || !byEncounter.ContainsKey(next)) {
return false;
}
if(next == target) {
return true;
}
if(!visited.Add(next)) {
return false;
}
current = next;
}
return false;
}
private static List<TreeViewItemData<Record>> BuildChainChildren(Record root, Dictionary<IEncounter, Record> byEncounter, Dictionary<IEncounter, IEncounter> parentOf, int depth, ref int uid) {
var children = new List<TreeViewItemData<Record>>();
if(root.encounter?.EncounterDefinition?.Kind is not QuestKind) {
return children;
}
var current = root.encounter;
var visited = new HashSet<IEncounter> { current };
var currentDepth = depth;
while(current?.EncounterDefinition?.Kind is QuestKind quest) {
var next = quest.nextEncounter.Resolve();
if(next == null || !visited.Add(next)) {
break;
}
if(!byEncounter.TryGetValue(next, out var nextRecord)) {
break;
}
if(!parentOf.TryGetValue(next, out var parent) || parent != current) {
break;
}
nextRecord.depth = currentDepth;
children.Add(new TreeViewItemData<Record>(uid++, nextRecord));
current = next;
currentDepth++;
}
return children;
}
private bool Matches(Record record) {
var kindName = record.encounter?.EncounterDefinition?.Kind?.GetType().Name ?? string.Empty;
if(kindFilter != AllKinds && kindName != kindFilter) {
return false;
}
if(string.IsNullOrEmpty(searchText)) {
return true;
}
var needle = searchText.ToLowerInvariant();
var definition = record.encounter?.EncounterDefinition;
return Contains(definition?.id, needle)
|| Contains(definition?.name, needle)
|| Contains(definition?.description, needle);
}
private static bool Contains(string source, string needle) {
return !string.IsNullOrEmpty(source) && source.ToLowerInvariant().Contains(needle);
}
private void OnSelectionChanged(IEnumerable<object> selection) {
var record = selection.OfType<Record>().FirstOrDefault();
if(record == null) {
ShowEmptyDetail();
return;
}
if(record.IsTableHeader) {
ShowTableHeaderDetail(record.table);
return;
}
detailPane.Clear();
var serializedObject = new SerializedObject(record.table);
var encountersProp = serializedObject.FindProperty(nameof(EncounterTable.encounters));
var elementProp = encountersProp.GetArrayElementAtIndex(record.index);
var headerRow = new VisualElement {
style = {
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
marginBottom = 6
}
};
headerRow.Add(new Label($"{record.table.name} → [{record.index}]") {
style = {
unityFontStyleAndWeight = FontStyle.Bold,
flexGrow = 1f,
color = new StyleColor(new Color(0.75f, 0.75f, 0.75f))
}
});
var pingButton = new Button(() => {
Selection.activeObject = record.table;
EditorGUIUtility.PingObject(record.table);
}) {
text = "Select Table",
tooltip = "Select and ping the owning EncounterTable asset in the Project window."
};
headerRow.Add(pingButton);
detailPane.Add(headerRow);
elementProp.isExpanded = true;
var field = new PropertyField(elementProp);
field.Bind(serializedObject);
detailPane.Add(field);
AddChainPreviewIfQuest(record);
}
private void AddChainPreviewIfQuest(Record record) {
if(record.encounter?.EncounterDefinition?.Kind is not QuestKind questKind) {
return;
}
var predecessor = FindPredecessor(record.encounter.EncounterDefinition?.internalId);
var next = questKind.nextEncounter.Resolve();
var predName = predecessor?.encounter.EncounterDefinition?.name ?? "—";
var currName = record.encounter.EncounterDefinition?.name ?? "<unnamed>";
var nextName = next?.EncounterDefinition?.name ?? "—";
var chainLabel = new Label($"Chain: {predName} ← {currName} → {nextName}") {
style = {
marginTop = 12,
paddingTop = 6,
paddingBottom = 6,
paddingLeft = 6,
paddingRight = 6,
backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.2f, 0.4f)),
unityFontStyleAndWeight = FontStyle.Bold
}
};
detailPane.Add(chainLabel);
}
private Record FindPredecessor(string targetId) {
if(string.IsNullOrEmpty(targetId)) {
return null;
}
foreach(var record in allRecords) {
if(record.encounter?.EncounterDefinition?.Kind is not QuestKind kind) {
continue;
}
var next = kind.nextEncounter.Resolve();
if(next?.EncounterDefinition?.internalId == targetId) {
return record;
}
}
return null;
}
private void ShowTableHeaderDetail(EncounterTable table) {
detailPane.Clear();
if(table == null) {
ShowEmptyDetail();
return;
}
var headerRow = new VisualElement {
style = {
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
marginBottom = 6
}
};
headerRow.Add(new Label(table.name) {
style = {
unityFontStyleAndWeight = FontStyle.Bold,
flexGrow = 1f
}
});
headerRow.Add(new Button(() => {
Selection.activeObject = table;
EditorGUIUtility.PingObject(table);
}) { text = "Select Table" });
detailPane.Add(headerRow);
var count = table.encounters?.Count ?? 0;
detailPane.Add(new Label($"{count} encounter(s). Expand the row to browse them, or pick one to edit.") {
style = { color = new StyleColor(Color.gray) }
});
}
private void ShowEmptyDetail() {
if(detailPane == null) {
return;
}
detailPane.Clear();
var empty = new Label("Select an encounter to edit.") {
style = { color = new StyleColor(Color.gray), marginTop = 8 }
};
detailPane.Add(empty);
}
}
}