Added the system from trail
This commit is contained in:
341
Editor/EncounterBrowserWindow.cs
Normal file
341
Editor/EncounterBrowserWindow.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
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>
|
||||
/// Designer-facing browser for every <see cref="IEncounter"/> authored across all
|
||||
/// <see cref="EncounterTable"/> assets in the project. Virtualised ListView on the left,
|
||||
/// property-field detail pane on the right. Search by id/name/description; filter by kind.
|
||||
/// </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 List<Record> filteredRecords = new();
|
||||
private string searchText = string.Empty;
|
||||
private string kindFilter = AllKinds;
|
||||
|
||||
private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new();
|
||||
|
||||
private ListView listView;
|
||||
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);
|
||||
|
||||
listView = new ListView {
|
||||
makeItem = MakeRow,
|
||||
bindItem = BindRow,
|
||||
fixedItemHeight = 22,
|
||||
selectionType = SelectionType.Single
|
||||
};
|
||||
listView.selectionChanged += OnSelectionChanged;
|
||||
listView.style.flexGrow = 1f;
|
||||
split.Add(listView);
|
||||
|
||||
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 = filteredRecords[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() {
|
||||
filteredRecords = allRecords.Where(Matches).ToList();
|
||||
if(listView != null) {
|
||||
listView.itemsSource = filteredRecords;
|
||||
listView.Rebuild();
|
||||
listView.ClearSelection();
|
||||
}
|
||||
ShowEmptyDetail();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterBrowserWindow.cs.meta
Normal file
2
Editor/EncounterBrowserWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0312d015c183e2f4582358d17867585c
|
||||
81
Editor/EncounterDrawer.cs
Normal file
81
Editor/EncounterDrawer.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Draws each <see cref="Encounter"/> list element with its <see cref="EncounterDefinition.id"/>
|
||||
/// as the foldout label (falling back to <c>name</c>, then the default element label).
|
||||
/// Children are iterated manually to avoid recursing into this drawer.
|
||||
/// </summary>
|
||||
[CustomPropertyDrawer(typeof(Encounter))]
|
||||
public class EncounterDrawer : PropertyDrawer {
|
||||
private const string DefinitionBackingField = "<EncounterDefinition>k__BackingField";
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
var displayLabel = ResolveLabel(property, label);
|
||||
|
||||
var foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
|
||||
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, displayLabel, true);
|
||||
|
||||
if(!property.isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
var h = EditorGUI.GetPropertyHeight(iterator, true);
|
||||
var r = new Rect(position.x, y, position.width, h);
|
||||
EditorGUI.PropertyField(r, iterator, true);
|
||||
y += h + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
var height = EditorGUIUtility.singleLineHeight;
|
||||
if(!property.isExpanded) {
|
||||
return height;
|
||||
}
|
||||
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private static GUIContent ResolveLabel(SerializedProperty property, GUIContent fallback) {
|
||||
var definition = property.FindPropertyRelative(DefinitionBackingField);
|
||||
if(definition == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var id = definition.FindPropertyRelative("id")?.stringValue;
|
||||
if(!string.IsNullOrEmpty(id)) {
|
||||
return new GUIContent(id);
|
||||
}
|
||||
|
||||
var name = definition.FindPropertyRelative("name")?.stringValue;
|
||||
if(!string.IsNullOrEmpty(name)) {
|
||||
return new GUIContent(name);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterDrawer.cs.meta
Normal file
2
Editor/EncounterDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ee5ffe2ea6903547ac75470249af31f
|
||||
86
Editor/EncounterLinkDrawer.cs
Normal file
86
Editor/EncounterLinkDrawer.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Two-row drawer for <see cref="EncounterLink"/>. Row 1 is an asset object-field for the target
|
||||
/// <see cref="EncounterTable"/>; row 2 is a dropdown of encounters inside that table labelled by
|
||||
/// <c>EncounterDefinition.name</c>. Picking a different table clears the stored internalId.
|
||||
/// </summary>
|
||||
[CustomPropertyDrawer(typeof(EncounterLink))]
|
||||
public class EncounterLinkDrawer : PropertyDrawer {
|
||||
private const string NonePlaceholder = "<none>";
|
||||
private const string EmptyTablePlaceholder = "<select a table first>";
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
var tableProp = property.FindPropertyRelative("table");
|
||||
var idProp = property.FindPropertyRelative("internalId");
|
||||
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
|
||||
var lineHeight = EditorGUIUtility.singleLineHeight;
|
||||
var spacing = EditorGUIUtility.standardVerticalSpacing;
|
||||
|
||||
var tableRect = new Rect(position.x, position.y, position.width, lineHeight);
|
||||
var encounterRect = new Rect(position.x, position.y + lineHeight + spacing, position.width, lineHeight);
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
EditorGUI.PropertyField(tableRect, tableProp, label);
|
||||
var tableChanged = EditorGUI.EndChangeCheck();
|
||||
|
||||
using(new EditorGUI.IndentLevelScope()) {
|
||||
var table = tableProp.objectReferenceValue as EncounterTable;
|
||||
if(table == null || table.encounters == null || table.encounters.Count == 0) {
|
||||
using(new EditorGUI.DisabledScope(true)) {
|
||||
EditorGUI.Popup(encounterRect, "Encounter", 0, new[] { EmptyTablePlaceholder });
|
||||
}
|
||||
if(tableChanged) {
|
||||
idProp.stringValue = string.Empty;
|
||||
}
|
||||
}
|
||||
else {
|
||||
DrawEncounterPicker(encounterRect, table, idProp, tableChanged);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.EndProperty();
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
return EditorGUIUtility.singleLineHeight * 2 + EditorGUIUtility.standardVerticalSpacing;
|
||||
}
|
||||
|
||||
private static void DrawEncounterPicker(Rect rect, EncounterTable table, SerializedProperty idProp, bool tableChanged) {
|
||||
var count = table.encounters.Count;
|
||||
var ids = new string[count + 1];
|
||||
var names = new string[count + 1];
|
||||
ids[0] = string.Empty;
|
||||
names[0] = NonePlaceholder;
|
||||
|
||||
var currentIndex = 0;
|
||||
for(int i = 0; i < count; i++) {
|
||||
var encounter = table.encounters[i];
|
||||
var id = encounter?.EncounterDefinition?.internalId ?? string.Empty;
|
||||
var name = encounter?.EncounterDefinition?.name;
|
||||
ids[i + 1] = id;
|
||||
names[i + 1] = string.IsNullOrEmpty(name)
|
||||
? $"<unnamed> ({encounter?.GetType().Name ?? "null"})"
|
||||
: name;
|
||||
if(!tableChanged && id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
|
||||
currentIndex = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if(tableChanged) {
|
||||
idProp.stringValue = string.Empty;
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
var newIndex = EditorGUI.Popup(rect, "Encounter", currentIndex, names);
|
||||
if(newIndex != currentIndex) {
|
||||
idProp.stringValue = ids[newIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterLinkDrawer.cs.meta
Normal file
2
Editor/EncounterLinkDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 385fe4b7b2663e54aa3f520a809a33bc
|
||||
266
Editor/EncounterValidator.cs
Normal file
266
Editor/EncounterValidator.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Generic;
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>Severity of a <see cref="ValidationIssue"/>.</summary>
|
||||
public enum ValidationSeverity {
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One thing the validator found wrong. <see cref="asset"/> is the click-through target
|
||||
/// (table, dialog option set, reward), <see cref="encounter"/> identifies the specific
|
||||
/// encounter the issue pertains to when applicable, and <see cref="path"/> describes where
|
||||
/// inside the asset the issue lives.
|
||||
/// </summary>
|
||||
public class ValidationIssue {
|
||||
public Object asset;
|
||||
public Encounter encounter;
|
||||
public string path;
|
||||
public ValidationSeverity severity;
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every <see cref="EncounterTable"/> and <see cref="Reward"/> asset in the project and
|
||||
/// returns the list of issues found. Intended to be driven from a menu item, the browser window,
|
||||
/// or pre-commit tooling. Runs on demand — does not cache.
|
||||
/// </summary>
|
||||
public static class EncounterValidator {
|
||||
/// <summary>Walk every encounter table and reward asset, returning all issues found.</summary>
|
||||
public static List<ValidationIssue> ValidateProject() {
|
||||
var issues = new List<ValidationIssue>();
|
||||
|
||||
var tables = FindAssetsOfType<EncounterTable>();
|
||||
var idIndex = new Dictionary<string, (Encounter encounter, EncounterTable table)>();
|
||||
|
||||
foreach(var table in tables) {
|
||||
ValidateTable(table, issues, idIndex);
|
||||
}
|
||||
|
||||
foreach(var reward in FindAssetsOfType<Reward>()) {
|
||||
ValidateReward(reward, issues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// <summary>Return the issues for a single encounter inside a table. Useful for per-row badges.</summary>
|
||||
public static List<ValidationIssue> ValidateEncounter(EncounterTable table, int index) {
|
||||
var issues = new List<ValidationIssue>();
|
||||
if(table?.encounters == null || index < 0 || index >= table.encounters.Count) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
var encounter = table.encounters[index];
|
||||
ValidateEncounterEntry(table, index, encounter, issues, duplicateCheck: null);
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static void ValidateTable(EncounterTable table, List<ValidationIssue> issues, Dictionary<string, (Encounter, EncounterTable)> idIndex) {
|
||||
if(table?.encounters == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for(int i = 0; i < table.encounters.Count; i++) {
|
||||
ValidateEncounterEntry(table, i, table.encounters[i], issues, idIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEncounterEntry(EncounterTable table, int index, Encounter encounter, List<ValidationIssue> issues, Dictionary<string, (Encounter, EncounterTable)> duplicateCheck) {
|
||||
var pathPrefix = $"encounters[{index}]";
|
||||
|
||||
if(encounter == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
path = pathPrefix,
|
||||
severity = ValidationSeverity.Error,
|
||||
message = "Null encounter entry."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var definition = encounter.EncounterDefinition;
|
||||
if(definition == null || string.IsNullOrEmpty(definition.internalId)) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
encounter = encounter,
|
||||
path = $"{pathPrefix}.EncounterDefinition.internalId",
|
||||
severity = ValidationSeverity.Error,
|
||||
message = "Missing internalId (cannot be referenced by EncounterLink)."
|
||||
});
|
||||
}
|
||||
else if(duplicateCheck != null) {
|
||||
if(duplicateCheck.TryGetValue(definition.internalId, out var prior)) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
encounter = encounter,
|
||||
path = $"{pathPrefix}.EncounterDefinition.internalId",
|
||||
severity = ValidationSeverity.Error,
|
||||
message = $"Duplicate internalId '{definition.internalId}' (also in table '{prior.Item2.name}')."
|
||||
});
|
||||
}
|
||||
else {
|
||||
duplicateCheck[definition.internalId] = (encounter, table);
|
||||
}
|
||||
}
|
||||
|
||||
if(encounter.Kind == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
encounter = encounter,
|
||||
path = $"{pathPrefix}.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);
|
||||
}
|
||||
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(link.table == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = owningTable,
|
||||
encounter = encounter,
|
||||
path = path,
|
||||
severity = ValidationSeverity.Error,
|
||||
message = "EncounterLink has internalId set but no table assigned."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if(link.Resolve() == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = owningTable,
|
||||
encounter = encounter,
|
||||
path = path,
|
||||
severity = ValidationSeverity.Error,
|
||||
message = $"Broken EncounterLink — table '{link.table.name}' has no encounter with internalId '{link.internalId}'."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDialogEvents(EncounterTable table, int index, Encounter encounter, List<ValidationIssue> issues) {
|
||||
var optionSet = encounter.EncounterDialogOptionSet;
|
||||
if(optionSet?.options == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for(int o = 0; o < optionSet.options.Count; o++) {
|
||||
var option = optionSet.options[o];
|
||||
if(option?.events == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for(int e = 0; e < option.events.Count; e++) {
|
||||
var evt = option.events[e];
|
||||
if(evt == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = optionSet,
|
||||
encounter = encounter,
|
||||
path = $"options[{o}].events[{e}]",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Null event entry (type may have been renamed/deleted)."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if(evt is GiveRewardEvent give && give.reward == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = optionSet,
|
||||
encounter = encounter,
|
||||
path = $"options[{o}].events[{e}].reward",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "GiveRewardEvent has no Reward asset assigned."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateReward(Reward reward, List<ValidationIssue> issues) {
|
||||
if(reward == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(reward.kind == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = reward,
|
||||
path = "kind",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Reward.kind is null — pick a reward kind in the inspector."
|
||||
});
|
||||
}
|
||||
|
||||
if(string.IsNullOrEmpty(reward.id)) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = reward,
|
||||
path = "id",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Reward has no id."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static List<T> FindAssetsOfType<T>() where T : Object {
|
||||
var result = new List<T>();
|
||||
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) {
|
||||
result.Add(asset);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Menu-driven runner that prints the validator report to the console with click-through asset pings.</summary>
|
||||
public static class EncounterValidatorMenu {
|
||||
[MenuItem("Jovian/Encounters/Validate All")]
|
||||
public static void ValidateAll() {
|
||||
var issues = EncounterValidator.ValidateProject();
|
||||
if(issues.Count == 0) {
|
||||
Debug.Log("[EncounterValidator] No issues found.");
|
||||
return;
|
||||
}
|
||||
|
||||
int errors = 0;
|
||||
int warnings = 0;
|
||||
foreach(var issue in issues) {
|
||||
if(issue.severity == ValidationSeverity.Error) {
|
||||
errors++;
|
||||
}
|
||||
else {
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[EncounterValidator] {issues.Count} issue(s) found — {errors} error(s), {warnings} warning(s). Click any log row to ping the offending asset.");
|
||||
|
||||
foreach(var issue in issues) {
|
||||
var assetName = issue.asset != null ? issue.asset.name : "<null>";
|
||||
var message = $"[EncounterValidator] {assetName} · {issue.path} — {issue.message}";
|
||||
if(issue.severity == ValidationSeverity.Error) {
|
||||
Debug.LogError(message, issue.asset);
|
||||
}
|
||||
else {
|
||||
Debug.LogWarning(message, issue.asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterValidator.cs.meta
Normal file
2
Editor/EncounterValidator.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bab1779bc53230a4291cd3ff35774558
|
||||
18
Editor/Jovian.EncounterSystem.Editor.asmdef
Normal file
18
Editor/Jovian.EncounterSystem.Editor.asmdef
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Jovian.EncounterSystem.Editor",
|
||||
"rootNamespace": "Jovian.EncounterSystem.Editor",
|
||||
"references": [
|
||||
"Jovian.EncounterSystem"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
7
Editor/Jovian.EncounterSystem.Editor.asmdef.meta
Normal file
7
Editor/Jovian.EncounterSystem.Editor.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa750d6ecf6f41540ab10d47c136fc33
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
157
Editor/SubclassSelectorDrawer.cs
Normal file
157
Editor/SubclassSelectorDrawer.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Custom drawer for any field marked <c>[SerializeReference, SubclassSelector]</c>. Renders a
|
||||
/// "pick a concrete type" dropdown populated by reflection over the declared base type, then
|
||||
/// draws the chosen instance's serialized children underneath. Works for single fields, arrays,
|
||||
/// and <see cref="List{T}"/> elements.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The type cache is a <c>static readonly</c> dictionary; it is implicitly cleared on domain
|
||||
/// reload because the static field is recreated with the new assembly image.
|
||||
/// </remarks>
|
||||
[CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
|
||||
public class SubclassSelectorDrawer : PropertyDrawer {
|
||||
private static readonly Dictionary<Type, Type[]> TypeCache = new();
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
if(property.propertyType != SerializedPropertyType.ManagedReference) {
|
||||
EditorGUI.LabelField(position, label.text, "[SubclassSelector] requires [SerializeReference]");
|
||||
return;
|
||||
}
|
||||
|
||||
var baseType = ResolveBaseType(fieldInfo.FieldType);
|
||||
var concreteTypes = GetConcreteTypes(baseType);
|
||||
var currentType = GetCurrentType(property);
|
||||
var currentIndex = Array.IndexOf(concreteTypes, currentType);
|
||||
|
||||
var names = new string[concreteTypes.Length + 1];
|
||||
names[0] = "<None>";
|
||||
for(int i = 0; i < concreteTypes.Length; i++) {
|
||||
names[i + 1] = concreteTypes[i].Name;
|
||||
}
|
||||
|
||||
var displayLabel = ResolveDisplayLabel(property, label);
|
||||
var headerRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
|
||||
var labelRect = new Rect(headerRect.x, headerRect.y, EditorGUIUtility.labelWidth, headerRect.height);
|
||||
var popupRect = new Rect(headerRect.x + EditorGUIUtility.labelWidth, headerRect.y, headerRect.width - EditorGUIUtility.labelWidth, headerRect.height);
|
||||
|
||||
EditorGUI.LabelField(labelRect, displayLabel);
|
||||
var newIndex = EditorGUI.Popup(popupRect, currentIndex + 1, names);
|
||||
if(newIndex != currentIndex + 1) {
|
||||
property.managedReferenceValue = newIndex == 0 ? null : Activator.CreateInstance(concreteTypes[newIndex - 1]);
|
||||
property.serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
if(property.managedReferenceValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
var h = EditorGUI.GetPropertyHeight(iterator, true);
|
||||
var r = new Rect(position.x, y, position.width, h);
|
||||
EditorGUI.PropertyField(r, iterator, true);
|
||||
y += h + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
var height = EditorGUIUtility.singleLineHeight;
|
||||
if(property.propertyType != SerializedPropertyType.ManagedReference || property.managedReferenceValue == null) {
|
||||
return height;
|
||||
}
|
||||
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private static Type ResolveBaseType(Type fieldType) {
|
||||
if(fieldType.IsArray) {
|
||||
return fieldType.GetElementType();
|
||||
}
|
||||
if(fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(List<>)) {
|
||||
return fieldType.GetGenericArguments()[0];
|
||||
}
|
||||
return fieldType;
|
||||
}
|
||||
|
||||
private static Type[] GetConcreteTypes(Type baseType) {
|
||||
if(TypeCache.TryGetValue(baseType, out var cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(assembly => {
|
||||
try {
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch {
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
})
|
||||
.Where(type => baseType.IsAssignableFrom(type)
|
||||
&& !type.IsAbstract
|
||||
&& !type.IsInterface
|
||||
&& !typeof(UnityEngine.Object).IsAssignableFrom(type)
|
||||
&& type.GetConstructor(Type.EmptyTypes) != null)
|
||||
.OrderBy(type => type.Name)
|
||||
.ToArray();
|
||||
TypeCache[baseType] = types;
|
||||
return types;
|
||||
}
|
||||
|
||||
private static GUIContent ResolveDisplayLabel(SerializedProperty property, GUIContent fallback) {
|
||||
var value = property.managedReferenceValue;
|
||||
if(value is not IEncounter) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var definitionProp = value.GetType().GetProperty("EncounterDefinition");
|
||||
var definition = definitionProp?.GetValue(value);
|
||||
var nameField = definition?.GetType().GetField("name");
|
||||
var name = nameField?.GetValue(definition) as string;
|
||||
return string.IsNullOrEmpty(name) ? fallback : new GUIContent(name);
|
||||
}
|
||||
|
||||
private static Type GetCurrentType(SerializedProperty property) {
|
||||
var full = property.managedReferenceFullTypename;
|
||||
if(string.IsNullOrEmpty(full)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var space = full.IndexOf(' ');
|
||||
if(space < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var assembly = full.Substring(0, space);
|
||||
var typeName = full.Substring(space + 1);
|
||||
return Type.GetType($"{typeName}, {assembly}");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/SubclassSelectorDrawer.cs.meta
Normal file
2
Editor/SubclassSelectorDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e00ec3e6bdfd024aa73689289578192
|
||||
Reference in New Issue
Block a user