fixed the encounter window

This commit is contained in:
Sebastian Bularca
2026-04-19 18:34:05 +02:00
parent ac9306154b
commit 2b48af8d3e
5 changed files with 398 additions and 54 deletions

View File

@@ -16,8 +16,19 @@ namespace Jovian.EncounterSystem.Editor {
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;
@@ -27,6 +38,7 @@ namespace Jovian.EncounterSystem.Editor {
private TreeView treeView;
private VisualElement detailPane;
private ToolbarMenu kindDropdown;
private VisualElement statusBanner;
[MenuItem("Jovian/Encounters/Encounter Browser")]
public static void Open() {
@@ -69,8 +81,26 @@ namespace Jovian.EncounterSystem.Editor {
}
private void BuildSplit() {
var split = new TwoPaneSplitView(0, 280, TwoPaneSplitViewOrientation.Horizontal);
split.style.flexGrow = 1f;
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 {
@@ -80,7 +110,10 @@ namespace Jovian.EncounterSystem.Editor {
selectionType = SelectionType.Single
};
treeView.selectionChanged += OnSelectionChanged;
treeView.style.flexGrow = 1f;
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) {
@@ -97,7 +130,9 @@ namespace Jovian.EncounterSystem.Editor {
alignItems = Align.Center,
paddingLeft = 6,
paddingRight = 6,
height = 22
height = 22,
flexGrow = 1f,
flexShrink = 0f
}
};
@@ -125,6 +160,19 @@ namespace Jovian.EncounterSystem.Editor {
};
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;
}
@@ -132,9 +180,34 @@ namespace Jovian.EncounterSystem.Editor {
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?.Kind?.GetType().Name ?? "—";
var kind = record.encounter?.EncounterDefinition?.Kind?.GetType().Name ?? "—";
label.text = string.IsNullOrEmpty(name)
? $"<unnamed> [{kind}]"
: $"{name} [{kind}]";
@@ -180,20 +253,33 @@ namespace Jovian.EncounterSystem.Editor {
private void Refresh() {
allRecords.Clear();
var guids = AssetDatabase.FindAssets("t:" + nameof(EncounterTable));
foreach(var guid in guids) {
var path = AssetDatabase.GUIDToAssetPath(guid);
var table = AssetDatabase.LoadAssetAtPath<EncounterTable>(path);
if(table?.encounters == null) {
var registries = FindAssetsOfType<EncounterRegistry>().ToList();
UpdateStatusBanner(registries);
var seenTables = new HashSet<EncounterTable>();
foreach(var registry in registries) {
if(registry?.encounterCollections == null) {
continue;
}
for(int i = 0; i < table.encounters.Count; i++) {
allRecords.Add(new Record {
table = table,
index = i,
encounter = table.encounters[i]
});
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]
});
}
}
}
}
@@ -201,6 +287,72 @@ namespace Jovian.EncounterSystem.Editor {
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();
@@ -219,16 +371,46 @@ namespace Jovian.EncounterSystem.Editor {
private void ApplyFilter() {
var filtered = allRecords.Where(Matches).ToList();
var items = BuildTreeItems(filtered);
if(treeView != null) {
treeView.SetRootItems(items);
treeView.Rebuild();
treeView.ClearSelection();
treeView.ExpandAll();
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) {
@@ -236,41 +418,81 @@ namespace Jovian.EncounterSystem.Editor {
}
}
// Any encounter that is a `nextEncounter` target of another record in the filtered set
// becomes a non-root (rendered as a child), not a top-level item.
var nonRoot = new HashSet<IEncounter>();
// 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?.Kind is not QuestKind quest) {
if(r.encounter?.EncounterDefinition?.Kind is not QuestKind quest) {
continue;
}
var next = quest.nextEncounter.Resolve();
if(next != null && byEncounter.ContainsKey(next)) {
nonRoot.Add(next);
predecessorCount.TryGetValue(next, out var count);
predecessorCount[next] = count + 1;
}
}
var result = new List<TreeViewItemData<Record>>();
var uid = 0;
var parentOf = new Dictionary<IEncounter, IEncounter>();
foreach(var r in records) {
if(r.encounter != null && nonRoot.Contains(r.encounter)) {
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;
}
var children = BuildChainChildren(r, byEncounter, ref uid);
result.Add(new TreeViewItemData<Record>(uid++, r, children));
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 result;
return nodes;
}
private static List<TreeViewItemData<Record>> BuildChainChildren(Record root, Dictionary<IEncounter, Record> byEncounter, ref int uid) {
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?.Kind is not QuestKind) {
if(root.encounter?.EncounterDefinition?.Kind is not QuestKind) {
return children;
}
var current = root.encounter;
var visited = new HashSet<IEncounter> { current };
while(current?.Kind is QuestKind quest) {
var currentDepth = depth;
while(current?.EncounterDefinition?.Kind is QuestKind quest) {
var next = quest.nextEncounter.Resolve();
if(next == null || !visited.Add(next)) {
break;
@@ -278,14 +500,19 @@ namespace Jovian.EncounterSystem.Editor {
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?.Kind?.GetType().Name ?? string.Empty;
var kindName = record.encounter?.EncounterDefinition?.Kind?.GetType().Name ?? string.Empty;
if(kindFilter != AllKinds && kindName != kindFilter) {
return false;
}
@@ -312,20 +539,40 @@ namespace Jovian.EncounterSystem.Editor {
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 header = new Label($"{record.table.name} → [{record.index}]") {
var headerRow = new VisualElement {
style = {
unityFontStyleAndWeight = FontStyle.Bold,
marginBottom = 6,
color = new StyleColor(new Color(0.75f, 0.75f, 0.75f))
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
marginBottom = 6
}
};
detailPane.Add(header);
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);
@@ -336,7 +583,7 @@ namespace Jovian.EncounterSystem.Editor {
}
private void AddChainPreviewIfQuest(Record record) {
if(record.encounter?.Kind is not QuestKind questKind) {
if(record.encounter?.EncounterDefinition?.Kind is not QuestKind questKind) {
return;
}
@@ -367,7 +614,7 @@ namespace Jovian.EncounterSystem.Editor {
}
foreach(var record in allRecords) {
if(record.encounter?.Kind is not QuestKind kind) {
if(record.encounter?.EncounterDefinition?.Kind is not QuestKind kind) {
continue;
}
@@ -379,6 +626,38 @@ namespace Jovian.EncounterSystem.Editor {
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;

View File

@@ -22,6 +22,8 @@ namespace Jovian.EncounterSystem.Editor {
public static List<ValidationIssue> ValidateProject() {
var issues = new List<ValidationIssue>();
ValidateRegistries(issues);
var tables = FindAssetsOfType<EncounterTable>();
var idIndex = new Dictionary<string, (Encounter encounter, EncounterTable table)>();
@@ -36,6 +38,42 @@ namespace Jovian.EncounterSystem.Editor {
return issues;
}
private static void ValidateRegistries(List<ValidationIssue> issues) {
var registries = FindAssetsOfType<EncounterRegistry>();
if(registries.Count == 0) {
issues.Add(new ValidationIssue {
asset = null,
path = "<project>",
severity = ValidationSeverity.Error,
message = "No EncounterRegistry asset exists. Create one via Jovian → Encounter System → Encounter Registry."
});
return;
}
foreach(var registry in registries) {
if(registry.encounterCollections == null || registry.encounterCollections.Length == 0) {
issues.Add(new ValidationIssue {
asset = registry,
path = "encounterCollections",
severity = ValidationSeverity.Warning,
message = "Registry has no collections assigned."
});
continue;
}
for(int i = 0; i < registry.encounterCollections.Length; i++) {
if(registry.encounterCollections[i] == null) {
issues.Add(new ValidationIssue {
asset = registry,
path = $"encounterCollections[{i}]",
severity = ValidationSeverity.Warning,
message = "Null collection slot."
});
}
}
}
}
public static List<ValidationIssue> ValidateEncounter(EncounterTable table, int index) {
var issues = new List<ValidationIssue>();
if(table?.encounters == null || index < 0 || index >= table.encounters.Count) {
@@ -95,25 +133,26 @@ namespace Jovian.EncounterSystem.Editor {
}
}
if(encounter.Kind == null) {
if(encounter.EncounterDefinition?.Kind == null) {
issues.Add(new ValidationIssue {
asset = table,
encounter = encounter,
path = $"{pathPrefix}.Kind",
path = $"{pathPrefix}.EncounterDefinition.Kind",
severity = ValidationSeverity.Warning,
message = "Encounter.Kind is null — pick a kind in the inspector."
});
}
if(encounter.Kind is QuestKind questKind) {
ValidateEncounterLink(table, pathPrefix + ".Kind.nextEncounter", encounter, questKind.nextEncounter, issues);
if(encounter.EncounterDefinition?.Kind is QuestKind questKind) {
ValidateEncounterLink(table, pathPrefix + ".EncounterDefinition.Kind.nextEncounter", encounter, questKind.nextEncounter, issues);
}
ValidateDialogEvents(table, index, encounter, issues);
}
private static void ValidateEncounterLink(EncounterTable owningTable, string path, Encounter encounter, EncounterLink link, List<ValidationIssue> issues) {
if(link.table == null && string.IsNullOrEmpty(link.internalId)) {
// No id picked — terminal quest step / unset link. Not an error regardless of table value.
if(string.IsNullOrEmpty(link.internalId)) {
return;
}

View File

@@ -42,5 +42,34 @@ namespace Jovian.EncounterSystem {
return pool[UnityEngine.Random.Range(0, pool.Count)];
}
#if UNITY_EDITOR
// Unity's inspector "+" duplicates the previous list element, including nested internalId
// GUIDs. Regenerate any duplicates so every encounter carries a unique internalId.
private void OnValidate() {
if(encounters == null) {
return;
}
var seen = new HashSet<string>();
var changed = false;
foreach(var encounter in encounters) {
if(encounter?.EncounterDefinition == null) {
continue;
}
var id = encounter.EncounterDefinition.internalId;
if(string.IsNullOrEmpty(id) || !seen.Add(id)) {
encounter.EncounterDefinition.internalId = Guid.NewGuid().ToString();
seen.Add(encounter.EncounterDefinition.internalId);
changed = true;
}
}
if(changed) {
UnityEditor.EditorUtility.SetDirty(this);
}
}
#endif
}
}

View File

@@ -8,7 +8,6 @@ namespace Jovian.EncounterSystem {
EncounterProperties EncounterProperties { get; set; }
EncounterVisuals EncounterVisuals { get; set; }
EncounterDialogOptionSet EncounterDialogOptionSet { get; set; }
IEncounterKind Kind { get; set; }
}
/// <summary>Default concrete encounter. Extend via a new <see cref="IEncounterKind"/>, not by subclassing.</summary>
@@ -18,9 +17,6 @@ namespace Jovian.EncounterSystem {
[field: SerializeField] public EncounterProperties EncounterProperties { get; set; }
[field: SerializeField] public EncounterVisuals EncounterVisuals { get; set; }
[field: SerializeField] public EncounterDialogOptionSet EncounterDialogOptionSet { get; set; }
[field: SerializeReference, SubclassSelector]
public IEncounterKind Kind { get; set; }
}
[Serializable]
@@ -32,6 +28,7 @@ namespace Jovian.EncounterSystem {
public string id;
public string name;
public string description;
[field: SerializeReference, SubclassSelector] public IEncounterKind Kind { get; set; }
}
[Serializable]

View File

@@ -35,7 +35,7 @@ namespace Jovian.EncounterSystem {
}
public void OnEncounterTriggered(IEncounter encounter) {
if(encounter?.Kind is not QuestKind questKind) {
if(encounter?.EncounterDefinition.Kind is not QuestKind questKind) {
return;
}
@@ -83,7 +83,7 @@ namespace Jovian.EncounterSystem {
}
foreach(var encounter in table.encounters) {
if(encounter?.Kind is not QuestKind questKind) {
if(encounter?.EncounterDefinition.Kind is not QuestKind questKind) {
continue;
}