forked from Shardstone/trail-into-darkness
added encounter system
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
[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() {
|
||||
var split = new TwoPaneSplitView(0, 280, TwoPaneSplitViewOrientation.Horizontal);
|
||||
split.style.flexGrow = 1f;
|
||||
rootVisualElement.Add(split);
|
||||
|
||||
treeView = new TreeView {
|
||||
makeItem = MakeRow,
|
||||
bindItem = BindRow,
|
||||
fixedItemHeight = 22,
|
||||
selectionType = SelectionType.Single
|
||||
};
|
||||
treeView.selectionChanged += OnSelectionChanged;
|
||||
treeView.style.flexGrow = 1f;
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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 name = record.encounter?.EncounterDefinition?.name;
|
||||
var kind = record.encounter?.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 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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for(int i = 0; i < table.encounters.Count; i++) {
|
||||
allRecords.Add(new Record {
|
||||
table = table,
|
||||
index = i,
|
||||
encounter = table.encounters[i]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
RebuildIssueIndex();
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
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) {
|
||||
treeView.SetRootItems(items);
|
||||
treeView.Rebuild();
|
||||
treeView.ClearSelection();
|
||||
treeView.ExpandAll();
|
||||
}
|
||||
ShowEmptyDetail();
|
||||
}
|
||||
|
||||
private static List<TreeViewItemData<Record>> BuildTreeItems(List<Record> records) {
|
||||
var byEncounter = new Dictionary<IEncounter, Record>();
|
||||
foreach(var r in records) {
|
||||
if(r.encounter != null) {
|
||||
byEncounter[r.encounter] = r;
|
||||
}
|
||||
}
|
||||
|
||||
// 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>();
|
||||
foreach(var r in records) {
|
||||
if(r.encounter?.Kind is not QuestKind quest) {
|
||||
continue;
|
||||
}
|
||||
var next = quest.nextEncounter.Resolve();
|
||||
if(next != null && byEncounter.ContainsKey(next)) {
|
||||
nonRoot.Add(next);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<TreeViewItemData<Record>>();
|
||||
var uid = 0;
|
||||
foreach(var r in records) {
|
||||
if(r.encounter != null && nonRoot.Contains(r.encounter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var children = BuildChainChildren(r, byEncounter, ref uid);
|
||||
result.Add(new TreeViewItemData<Record>(uid++, r, children));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<TreeViewItemData<Record>> BuildChainChildren(Record root, Dictionary<IEncounter, Record> byEncounter, ref int uid) {
|
||||
var children = new List<TreeViewItemData<Record>>();
|
||||
if(root.encounter?.Kind is not QuestKind) {
|
||||
return children;
|
||||
}
|
||||
|
||||
var current = root.encounter;
|
||||
var visited = new HashSet<IEncounter> { current };
|
||||
while(current?.Kind is QuestKind quest) {
|
||||
var next = quest.nextEncounter.Resolve();
|
||||
if(next == null || !visited.Add(next)) {
|
||||
break;
|
||||
}
|
||||
if(!byEncounter.TryGetValue(next, out var nextRecord)) {
|
||||
break;
|
||||
}
|
||||
children.Add(new TreeViewItemData<Record>(uid++, nextRecord));
|
||||
current = next;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
private bool Matches(Record record) {
|
||||
var kindName = record.encounter?.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;
|
||||
}
|
||||
|
||||
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}]") {
|
||||
style = {
|
||||
unityFontStyleAndWeight = FontStyle.Bold,
|
||||
marginBottom = 6,
|
||||
color = new StyleColor(new Color(0.75f, 0.75f, 0.75f))
|
||||
}
|
||||
};
|
||||
detailPane.Add(header);
|
||||
|
||||
elementProp.isExpanded = true;
|
||||
var field = new PropertyField(elementProp);
|
||||
field.Bind(serializedObject);
|
||||
detailPane.Add(field);
|
||||
|
||||
AddChainPreviewIfQuest(record);
|
||||
}
|
||||
|
||||
private void AddChainPreviewIfQuest(Record record) {
|
||||
if(record.encounter?.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?.Kind is not QuestKind kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = kind.nextEncounter.Resolve();
|
||||
if(next?.EncounterDefinition?.internalId == targetId) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user