First commit

This commit is contained in:
sbularca
2026-05-19 15:52:04 +02:00
commit 27b7aeee46
1660 changed files with 240354 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7d89f89053ce0384c9f7b48a5b491bca
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,106 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Id dropdown (library inherited from the owning asset's <c>library</c> field) + inline fallback + resolved preview.</summary>
[CustomPropertyDrawer(typeof(DialogLineRef))]
public class DialogLineRefDrawer : PropertyDrawer {
private const string NonePlaceholder = "<none>";
private const string EmptyLibraryPlaceholder = "<set library on the parent asset>";
private const float PreviewHeight = 32f;
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
var idProp = property.FindPropertyRelative("id");
var inlineProp = property.FindPropertyRelative("inlineText");
var library = ResolveLibrary(property);
EditorGUI.BeginProperty(position, label, property);
var lineHeight = EditorGUIUtility.singleLineHeight;
var spacing = EditorGUIUtility.standardVerticalSpacing;
var idRect = new Rect(position.x, position.y, position.width, lineHeight);
DrawIdPicker(idRect, library, idProp, label);
var inlineRect = new Rect(
position.x,
position.y + lineHeight + spacing,
position.width,
lineHeight * 2);
EditorGUI.PropertyField(inlineRect, inlineProp, new GUIContent("Inline"));
var previewRect = new Rect(
position.x,
position.y + lineHeight + spacing + lineHeight * 2 + spacing,
position.width,
PreviewHeight);
DrawPreview(previewRect, library, idProp, inlineProp);
EditorGUI.EndProperty();
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var lineHeight = EditorGUIUtility.singleLineHeight;
var spacing = EditorGUIUtility.standardVerticalSpacing;
// id + (inline 2 lines) + preview
return lineHeight + spacing + lineHeight * 2 + spacing + PreviewHeight;
}
private static DialogLineLibrary ResolveLibrary(SerializedProperty property) {
var libraryProp = property.serializedObject.FindProperty("library");
return libraryProp?.objectReferenceValue as DialogLineLibrary;
}
private static void DrawIdPicker(Rect rect, DialogLineLibrary library, SerializedProperty idProp, GUIContent label) {
if(library == null || library.lines == null || library.lines.Count == 0) {
using(new EditorGUI.DisabledScope(true)) {
EditorGUI.Popup(rect, label.text, 0, new[] { EmptyLibraryPlaceholder });
}
return;
}
var count = library.lines.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 line = library.lines[i];
var id = line?.id ?? string.Empty;
ids[i + 1] = id;
names[i + 1] = string.IsNullOrEmpty(id) ? $"<unnamed {i}>" : id;
if(id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
currentIndex = i + 1;
}
}
var newIndex = EditorGUI.Popup(rect, label.text, currentIndex, names);
if(newIndex != currentIndex) {
idProp.stringValue = ids[newIndex];
}
}
private static void DrawPreview(Rect rect, DialogLineLibrary library, SerializedProperty idProp, SerializedProperty inlineProp) {
var id = idProp.stringValue;
var inline = inlineProp.stringValue;
string resolved = null;
if(library != null && !string.IsNullOrEmpty(id)) {
resolved = library.Resolve(id);
}
if(string.IsNullOrEmpty(resolved)) {
resolved = inline;
}
var style = new GUIStyle(EditorStyles.helpBox) {
fontStyle = FontStyle.Italic,
wordWrap = true
};
var label = string.IsNullOrEmpty(resolved) ? "<no text will display>" : resolved;
EditorGUI.LabelField(rect, new GUIContent("Preview: " + label), style);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a55b866cc153f5749a71928930faeb62
@@ -0,0 +1,673 @@
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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0312d015c183e2f4582358d17867585c
@@ -0,0 +1,76 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Label list elements with the option's text id (fallback: inline text preview, then default).</summary>
[CustomPropertyDrawer(typeof(EncounterDialogOption))]
public class EncounterDialogOptionDrawer : PropertyDrawer {
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 textProp = property.FindPropertyRelative("text");
if(textProp == null) {
return fallback;
}
var id = textProp.FindPropertyRelative("id")?.stringValue;
if(!string.IsNullOrEmpty(id)) {
return new GUIContent(id);
}
var inline = textProp.FindPropertyRelative("inlineText")?.stringValue;
if(!string.IsNullOrEmpty(inline)) {
var preview = inline.Length > 40 ? inline.Substring(0, 40) + "…" : inline;
return new GUIContent(preview);
}
return fallback;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5636dc9f15ce02c4ca189c3d5f46eb34
@@ -0,0 +1,76 @@
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Label list elements with the encounter id (fallback: name, then default).</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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ee5ffe2ea6903547ac75470249af31f
@@ -0,0 +1,82 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Table object-field + encounter dropdown picker. Changing tables clears the id.</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];
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 385fe4b7b2663e54aa3f520a809a33bc
@@ -0,0 +1,335 @@
using System.Collections.Generic;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
public enum ValidationSeverity {
Warning,
Error
}
public class ValidationIssue {
public Object asset;
public Encounter encounter;
public string path;
public ValidationSeverity severity;
public string message;
}
/// <summary>Project-wide scan of encounter tables and rewards. Runs on demand, no caching.</summary>
public static class EncounterValidator {
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)>();
foreach(var table in tables) {
ValidateTable(table, issues, idIndex);
}
foreach(var reward in FindAssetsOfType<Reward>()) {
ValidateReward(reward, issues);
}
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) {
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.EncounterDefinition?.Kind == null) {
issues.Add(new ValidationIssue {
asset = table,
encounter = encounter,
path = $"{pathPrefix}.EncounterDefinition.Kind",
severity = ValidationSeverity.Warning,
message = "Encounter.Kind is null — pick a kind in the inspector."
});
}
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) {
// No id picked — terminal quest step / unset link. Not an error regardless of table value.
if(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 == null) {
continue;
}
ValidateDialogLineRef(optionSet, encounter, $"options[{o}].text", option.text, issues);
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 ValidateDialogLineRef(EncounterDialogOptionSet optionSet, Encounter encounter, string path, DialogLineRef lineRef, List<ValidationIssue> issues) {
var library = optionSet.library;
var hasId = !string.IsNullOrEmpty(lineRef.id);
var hasInline = !string.IsNullOrEmpty(lineRef.inlineText);
if(!hasId && !hasInline) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = path,
severity = ValidationSeverity.Warning,
message = "Dialog line is empty (no id and no inline text)."
});
return;
}
if(hasId && library == null) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = path,
severity = ValidationSeverity.Error,
message = $"DialogLineRef references id '{lineRef.id}' but the option set has no library assigned."
});
return;
}
if(hasId && library != null && string.IsNullOrEmpty(library.Resolve(lineRef.id))) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = path,
severity = ValidationSeverity.Error,
message = $"DialogLineRef id '{lineRef.id}' not found in library '{library.name}'."
});
}
}
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;
}
}
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);
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bab1779bc53230a4291cd3ff35774558
@@ -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
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: fa750d6ecf6f41540ab10d47c136fc33
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Concrete-type dropdown for <c>[SerializeReference]</c> fields, arrays, and list elements.</summary>
[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}");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e00ec3e6bdfd024aa73689289578192
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sebastian Bularca
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 181516b3331a8824abeaa656991d77ee
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,186 @@
# Jovian Encounter System
Data-driven encounter authoring for Unity. One composable `Encounter` type carrying a polymorphic `IEncounterKind` payload, dialog options with designer-authored events, cross-table quest chaining, and gated quest progression that serialises cleanly.
## Package Structure
```
Packages/com.jovian.encounter-system/
├── Runtime/
│ ├── IEncounter.cs ← interface + Encounter + definition/visuals/properties
│ ├── IEncounterKind.cs ← marker interface + Combat/Quest/Social/Puzzle/...
│ ├── EncounterTable.cs ← ScriptableObject: list of encounters + roll helpers
│ ├── EncountersCollection.cs ← ScriptableObject: group of tables
│ ├── EncounterDialogOptionSet.cs ← ScriptableObject: shared option list (auto-renames to id)
│ ├── DialogLineLibrary.cs ← ScriptableObject: id → text registry for reusable lines
│ ├── DialogLineRef.cs ← struct: library+id reference with inline fallback
│ ├── EncounterLink.cs ← cross-table reference (table + internalId)
│ ├── EncounterReference.cs ← MonoBehaviour scaffold wiring common UI fields
│ ├── EncounterRegistry.cs ← id → encounter lookup cache
│ ├── SubclassSelectorAttribute.cs← attribute powering the concrete-type dropdown
│ ├── IEncounterEvent.cs ← event interface + ChainTo/StartCombat/Log/GiveReward starters
│ ├── Reward.cs ← ScriptableObject: reward asset (id + displayName + icon + kind)
│ ├── IRewardKind.cs ← marker interface + Currency/Item/Experience/Unlockable/Other
│ ├── EncounterContext.cs ← per-resolution data bag
│ ├── EncounterResolver.cs ← Register<T>/Resolve dispatch by concrete event type
│ ├── QuestProgress.cs ← gated progression + QuestStarted/Advanced/Completed events
│ └── QuestLog.cs ← chronological serialisable record of quest events
└── Editor/
├── SubclassSelectorDrawer.cs ← dropdown + inline children for [SerializeReference] fields
├── EncounterLinkDrawer.cs ← two-row table + encounter picker
├── DialogLineRefDrawer.cs ← library+id picker with inline fallback and live preview
├── EncounterDrawer.cs ← list element label shows encounter id
├── EncounterValidator.cs ← project-wide scan + "Validate All" menu + browser badges
└── EncounterBrowserWindow.cs ← Jovian → Encounters → Encounter Browser
```
## Quick Start
1. Add the package to your project (local package in `Packages/`).
2. **Create a table**: `Assets → Create → Jovian → Encounter System → Encounter Table`.
3. **Create a collection**: `Assets → Create → Jovian → Encounter System → Encounters Collection`, drag tables into its array.
4. In the table inspector, add encounters to the list. For each: pick the **kind** in the dropdown, fill in shared fields (id/name/description), and populate the kind-specific payload.
5. For a quest chain, set `QuestKind.nextEncounter` on each step — pick the target table, then pick the encounter inside it.
6. **Browse everything**: `Jovian → Encounters → Encounter Browser` for a searchable, filterable list view.
## Key Concepts
### `Encounter` is one class; `IEncounterKind` is where types live
A single concrete `Encounter` carries shared fields (`EncounterDefinition`, `EncounterProperties`, `EncounterVisuals`, `EncounterDialogOptionSet`). Its `Kind` property is a `[SerializeReference, SubclassSelector]` polymorphic slot. Adding a new kind is one small class:
```csharp
[Serializable]
public class MerchantKind : IEncounterKind {
public string shopInventoryId;
public float priceMultiplier = 1f;
}
```
The editor dropdown picks it up automatically via reflection. No base class to extend, no subclass of `Encounter` to write.
### Dialog option events are designer-authored, code-dispatched
Each `EncounterDialogOption` holds an ordered `List<IEncounterEvent>` (also `[SerializeReference, SubclassSelector]`). Events are data only — what happens when the option is chosen is determined by handlers registered on `EncounterResolver`:
```csharp
var resolver = new EncounterResolver();
resolver.Register<StartCombatEvent>((evt, ctx) => combatSystem.Start(evt.combatEncounterId));
resolver.Register<ChainToEncounterEvent>((evt, ctx) => encounterFlow.GoTo(evt.nextEncounterId));
resolver.Register<LogEvent>((evt, _) => Debug.Log(evt.message));
// When the player picks an option:
resolver.Resolve(chosenOption.events, new EncounterContext(currentEncounter));
```
### Rewards are reusable assets, referenced by events
`Reward` is a ScriptableObject — one file per reusable reward (`10_Gold.asset`, `Rusty_Sword.asset`, `Unlock_Riverfort.asset`). Each asset has shared metadata (id, display name, icon) plus a polymorphic `IRewardKind` payload:
```csharp
[Serializable]
public class PotionOfHealingRewardKind : IRewardKind {
public int healAmount = 50;
}
```
To grant rewards, drop `GiveRewardEvent`s onto a dialog option and assign a `Reward` asset to each. Multiple rewards = multiple events — the resolver iterates them in order.
Game-side wiring applies the reward:
```csharp
resolver.Register<GiveRewardEvent>((evt, ctx) => rewardApplier.Apply(evt.reward, ctx));
```
`rewardApplier` lives in game code — the package ships only the data types.
### Reusable dialog lines via `DialogLineLibrary` + `DialogLineRef`
A single `DialogLineLibrary` asset (or a handful split by topic) holds `{ id → text }` entries. Every `EncounterDialogOption.text` is a `DialogLineRef` struct that resolves in this order:
1. If the library reference + id resolves, use that.
2. Otherwise fall back to `inlineText`.
The drawer shows all three inputs plus a live preview of the final text — WYSIWYG survives. Designers prototype inline and promote common lines to the library later without changing the field's type.
```csharp
resolver.Register<SomeEvent>((evt, ctx) => {
var text = option.text.Resolve(); // library lookup, inline fallback, null if both empty
});
```
### Cross-table references via `EncounterLink`
`EncounterLink` stores a table asset + stable `internalId` GUID. Rename-safe (the GUID doesn't change) and diffable. The custom drawer renders two dropdowns: first pick a table, then pick an encounter inside it.
### Gated quest progression
`QuestProgress` walks an `EncountersCollection` at construction and builds a map of "predecessor quest encounter" for every target of a `QuestKind.nextEncounter`. An encounter is gated iff its predecessor hasn't been resolved:
```csharp
var questProgress = new QuestProgress(encountersCollection);
// When rolling from a table, exclude gated entries:
var next = table.GetRandomEncounter(e => !questProgress.IsGated(e));
// After showing any encounter to the party:
questProgress.OnEncounterTriggered(shown);
```
Progression fires `QuestStarted` / `QuestAdvanced(from, to)` / `QuestCompleted` events. Single-step quests (root with no `nextEncounter`) fire both `QuestStarted` and `QuestCompleted`.
### Quest log (serialisable record)
`QuestLog` subscribes to the three `QuestProgress` events and appends a `QuestLogEntry` for each. `CreateSnapshot()` returns the save payload; `Restore(saved)` rehydrates. Pair it with `QuestProgress.LoadResolvedIds(questLog.ResolvedEncounterIds())` on load to restore the gating set.
## Runtime API Cheatsheet
```csharp
// Encounter rolling with gating
IEncounter next = table.GetRandomEncounter(e => !questProgress.IsGated(e));
// Resolving a picked dialog option
resolver.Resolve(chosenOption.events, new EncounterContext(currentEncounter));
// After any encounter fires
questProgress.OnEncounterTriggered(shown);
// Save/load quest state
save.questLogEntries = questLog.CreateSnapshot();
questLog.Restore(save.questLogEntries);
questProgress.LoadResolvedIds(questLog.ResolvedEncounterIds());
// Fast id → encounter lookup
encounterRegistry.GetEncounters().TryGetValue(id, out var encounter);
```
## Menu Items
| Menu Path | Description |
|-----------|-------------|
| Assets → Create → Jovian → Encounter System → Encounter Table | New table asset |
| Assets → Create → Jovian → Encounter System → Encounters Collection | New collection asset |
| Assets → Create → Jovian → Encounter System → Dialog Option Set | New dialog option set (auto-renames to id) |
| Assets → Create → Jovian → Encounter System → Dialog Line Library | New shared dialog line library |
| Assets → Create → Jovian → Encounter System → Encounter Registry | New registry asset |
| Jovian → Encounters → Encounter Browser | Searchable designer browser |
| Jovian → Encounters → Validate All | Scan all tables/rewards for issues, print click-through report |
## Composition Pattern
Per the project's composition-over-inheritance stance, extending behaviour means:
- **New encounter type?** Add a new `IEncounterKind` implementation.
- **New dialog side effect?** Add a new `IEncounterEvent` + `resolver.Register<T>`.
- **Never** subclass `Encounter` or stack `IEncounter` inheritance chains.
Inheritance is only used where Unity effectively requires it — ScriptableObject assets (`EncounterTable`, `EncountersCollection`, `EncounterDialogOptionSet`, `EncounterRegistry`) and the `EncounterReference` MonoBehaviour scaffold.
## Known Fragilities
- `[SerializeReference]` stores the concrete type's full name + assembly in the YAML. Renaming or moving an `IEncounterKind` or `IEncounterEvent` class drops existing instances silently. Use `[MovedFrom]` when refactoring:
```csharp
[MovedFrom("Jovian.EncounterSystem", "Jovian.EncounterSystem", "StartCombatEvent")]
public class BeginCombatEvent : IEncounterEvent { ... }
```
- `EncounterDefinition.internalId` is a GUID created at class construction. If you duplicate an encounter by copying YAML, dedupe the id before saving — otherwise `EncounterLink` lookups become ambiguous.
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 158607149986285429176c786851ed82
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a3909887107f1f24aad1cd7db8a0bb28
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
[Serializable]
public class DialogLine {
public string id;
[TextArea(2, 6)] public string text;
}
/// <summary>Flat registry of reusable dialog lines. Referenced via <see cref="DialogLineRef"/>.</summary>
[CreateAssetMenu(fileName = "DialogLineLibrary", menuName = "Jovian/Encounter System/Dialog Line Library", order = 4)]
public class DialogLineLibrary : ScriptableObject {
public List<DialogLine> lines = new();
private Dictionary<string, string> cache;
public string Resolve(string id) {
if(string.IsNullOrEmpty(id)) {
return null;
}
EnsureCache();
return cache.GetValueOrDefault(id);
}
public void InvalidateCache() {
cache = null;
}
private void EnsureCache() {
if(cache != null) {
return;
}
cache = new Dictionary<string, string>(lines?.Count ?? 0);
if(lines == null) {
return;
}
for(int i = 0; i < lines.Count; i++) {
var line = lines[i];
if(line != null && !string.IsNullOrEmpty(line.id)) {
cache[line.id] = line.text;
}
}
}
#if UNITY_EDITOR
private void OnValidate() {
InvalidateCache();
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 142d6e5b0f6a6cb41beddeae92b56fee
@@ -0,0 +1,19 @@
using System;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Looks up <see cref="id"/> in the library passed to <see cref="Resolve"/>; falls back to <see cref="inlineText"/>.</summary>
[Serializable]
public struct DialogLineRef {
public string id;
[TextArea(2, 6)] public string inlineText;
public string Resolve(DialogLineLibrary library) {
if(!library || string.IsNullOrEmpty(id)) {
return inlineText;
}
var text = library.Resolve(id);
return !string.IsNullOrEmpty(text) ? text : inlineText;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b9a6f9e062e474e4b9e7172f4230ca95
@@ -0,0 +1,10 @@
namespace Jovian.EncounterSystem {
/// <summary>Per-resolution scratch object passed to every event handler. Extend with fields as handlers need them.</summary>
public class EncounterContext {
public IEncounter CurrentEncounter { get; }
public EncounterContext(IEncounter currentEncounter) {
CurrentEncounter = currentEncounter;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d643c80eeffa65f4d804a795ba13bdf9
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Reusable dialog option list. Asset file auto-renames to match <see cref="id"/>.</summary>
[CreateAssetMenu(fileName = "EncounterDialogOptionSet", menuName = "Jovian/Encounter System/Dialog Option Set", order = 2)]
public class EncounterDialogOptionSet : ScriptableObject {
public string id;
/// <summary>Shared library for every option's <see cref="EncounterDialogOption.text"/> lookup.</summary>
public DialogLineLibrary library;
public List<EncounterDialogOption> options;
#if UNITY_EDITOR
private void OnValidate() {
if(string.IsNullOrEmpty(id)) {
return;
}
// AssetDatabase calls are unsafe from OnValidate — defer.
UnityEditor.EditorApplication.delayCall += RenameToMatchId;
}
private void RenameToMatchId() {
if(!this || string.IsNullOrEmpty(id)) {
return;
}
var path = UnityEditor.AssetDatabase.GetAssetPath(this);
if(string.IsNullOrEmpty(path) || name == id) {
return;
}
var error = UnityEditor.AssetDatabase.RenameAsset(path, id);
if(!string.IsNullOrEmpty(error)) {
Debug.LogWarning($"[EncounterDialogOptionSet] Could not rename '{path}' to '{id}': {error}", this);
}
}
#endif
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c47caaa92bb94eeca3e47dd86fd010cf
timeCreated: 1776587040
@@ -0,0 +1,19 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Cross-table reference. Rename-safe — the stored key is a GUID.</summary>
[Serializable]
public struct EncounterLink {
public EncounterTable table;
public string internalId;
/// <summary>O(1) lookup via <see cref="EncounterTable.Resolve"/>. Returns <c>null</c> if the
/// table is missing or the id can't be found.</summary>
public IEncounter Resolve() {
if(table == null || string.IsNullOrEmpty(internalId)) {
return null;
}
return table.Resolve(internalId);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 60455ea141b903c4390dbcdc29b46f99
@@ -0,0 +1,13 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Jovian.EncounterSystem {
/// <summary>Shared view scaffold — game code binds an <see cref="IEncounter"/> to these widgets.</summary>
public class EncounterReference : MonoBehaviour {
public TextMeshProUGUI encounterName;
public TextMeshProUGUI encounterDescription;
public Image encounterArt;
public Transform encounterOptionsContainer;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0173ea7fbf1e45b1932694938ecd3058
timeCreated: 1776508767
@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
namespace Jovian.EncounterSystem {
/// <summary>id → encounter cache. Editor auto-repopulates on asset changes; runtime must call <see cref="PopulateEncounters"/>.</summary>
[CreateAssetMenu(fileName = "EncounterRegistry", menuName = "Jovian/Encounter System/Encounter Registry")]
public class EncounterRegistry : ScriptableObject {
public EncountersCollection[] encounterCollections = Array.Empty<EncountersCollection>();
private readonly Dictionary<string, IEncounter> encounters = new();
public Dictionary<string, IEncounter> GetEncounters() => encounters;
public void RegisterEncounter(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return;
}
encounters.TryAdd(id, encounter);
}
public void UnregisterEncounter(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return;
}
encounters.Remove(id);
}
public void ClearEncounters() {
encounters.Clear();
}
public void PopulateEncounters() {
if(encounterCollections == null) {
return;
}
for(int c = 0; c < encounterCollections.Length; c++) {
var collection = encounterCollections[c];
if(collection?.encounterTables == null) {
continue;
}
for(int t = 0; t < collection.encounterTables.Length; t++) {
var table = collection.encounterTables[t];
if(table?.encounters == null) {
continue;
}
for(int i = 0; i < table.encounters.Count; i++) {
RegisterEncounter(table.encounters[i]);
}
}
}
}
public EncounterTable GetEncounterTable(string id) {
foreach(var collection in encounterCollections) {
foreach(var table in collection.encounterTables) {
if(table.id == id) {
return table;
}
}
}
return null;
}
public IEncounter GetRandomEncounter(string encounterTableId) {
var table = GetEncounterTable(encounterTableId);
if(!table) {
return null;
}
var ec = table.encounters;
return ec[UnityEngine.Random.Range(0, ec.Count)];
}
/// <summary>Random pick restricted to encounters whose runtime type matches <paramref name="type"/>.
/// Zero-alloc — uses reservoir sampling instead of building an intermediate filtered list.</summary>
public IEncounter GetRandomEncounter(string encounterTableId, IEncounterKind encounterKind) {
var table = GetEncounterTable(encounterTableId);
if(!table || encounterKind == null) {
return null;
}
IEncounter selected = null;
var seen = 0;
for(var i = 0; i < encounters.Count; i++) {
var encounter = table.encounters[i];
if(encounter == null || encounter.GetType() != encounterKind.GetType()) {
continue;
}
seen++;
if(UnityEngine.Random.Range(0, seen) == 0) {
selected = encounter;
}
}
return selected;
}
/// <summary>Random pick restricted by <paramref name="filter"/>. Used with
/// <see cref="QuestProgress.IsGated"/> to exclude gated encounters. Zero-alloc via reservoir sampling.</summary>
public IEncounter GetRandomEncounter(Predicate<IEncounter> filter, string encounterTableId) {
var table = GetEncounterTable(encounterTableId);
if(!table) {
return null;
}
if(filter == null) {
return GetRandomEncounter(encounterTableId);
}
IEncounter selected = null;
var seen = 0;
foreach(var encounter in table.encounters) {
if(encounter == null || !filter(encounter)) {
continue;
}
seen++;
if(UnityEngine.Random.Range(0, seen) == 0) {
selected = encounter;
}
}
return selected;
}
}
#if UNITY_EDITOR
/// <summary>Rebuilds the registry (Addressables key "EncounterRegistry") on any asset import/move/delete.</summary>
public class EncounterRegister : UnityEditor.AssetPostprocessor {
private static EncounterRegistry registryCache;
private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) {
registryCache ??= Addressables.LoadAssetAsync<EncounterRegistry>("EncounterRegistry").WaitForCompletion();
registryCache.ClearEncounters();
registryCache.PopulateEncounters();
}
}
#endif
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0dfee180882d49c9a3d4474f389d4905
timeCreated: 1776584974
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
/// <summary>Dispatches <see cref="IEncounterEvent"/> instances to per-type handlers. Unknown types are skipped.</summary>
public class EncounterResolver {
private readonly Dictionary<Type, Action<IEncounterEvent, EncounterContext>> handlers = new();
public void Register<T>(Action<T, EncounterContext> handler) where T : IEncounterEvent {
// Wrap the typed delegate so the dictionary can hold handlers for any event type uniformly.
// Cast is safe because the wrapper is only invoked via lookup under typeof(T).
handlers[typeof(T)] = (evt, ctx) => handler((T)evt, ctx);
}
public void Unregister<T>() where T : IEncounterEvent {
handlers.Remove(typeof(T));
}
/// <summary>Indexed iteration over <paramref name="events"/> — avoids the boxed enumerator
/// that an <c>IEnumerable&lt;T&gt;</c> parameter would force. <see cref="EncounterDialogOption.events"/>
/// is a <c>List</c>, which implements <c>IReadOnlyList</c>, so call sites just pass it directly.</summary>
public void Resolve(IReadOnlyList<IEncounterEvent> events, EncounterContext context) {
if(events == null) {
return;
}
var count = events.Count;
for(int i = 0; i < count; i++) {
var evt = events[i];
if(evt == null) {
continue;
}
if(handlers.TryGetValue(evt.GetType(), out var handler)) {
handler(evt, context);
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d3f07fa2d5f9804d8c75e7026566757
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
[CreateAssetMenu(fileName = "EncounterTable", menuName = "Jovian/Encounter System/Encounter Table", order = 1)]
public class EncounterTable : ScriptableObject {
public string id;
public List<Encounter> encounters;
private Dictionary<string, Encounter> idCache;
/// <summary>O(1) lookup by <see cref="EncounterDefinition.internalId"/>. Used by <see cref="EncounterLink"/>.
/// Call <see cref="InvalidateCache"/> if you mutate the <see cref="encounters"/> list at runtime.</summary>
public IEncounter Resolve(string internalId) {
if(string.IsNullOrEmpty(internalId) || encounters == null || encounters.Count == 0) {
return null;
}
EnsureCache();
return idCache.GetValueOrDefault(internalId);
}
/// <summary>Force the id lookup cache to rebuild on next use.</summary>
public void InvalidateCache() {
idCache = null;
}
private void EnsureCache() {
if(idCache != null) {
return;
}
idCache = new Dictionary<string, Encounter>(encounters?.Count ?? 0);
if(encounters == null) {
return;
}
for(var i = 0; i < encounters.Count; i++) {
var encounter = encounters[i];
var internalId = encounter?.EncounterDefinition?.internalId;
if(!string.IsNullOrEmpty(internalId)) {
idCache[internalId] = encounter;
}
}
}
#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() {
InvalidateCache();
if(encounters == null) {
return;
}
var seen = new HashSet<string>(encounters.Count);
var changed = false;
for(int i = 0; i < encounters.Count; i++) {
var encounter = encounters[i];
if(encounter?.EncounterDefinition == null) {
continue;
}
var entryId = encounter.EncounterDefinition.internalId;
if(string.IsNullOrEmpty(entryId) || !seen.Add(entryId)) {
encounter.EncounterDefinition.internalId = Guid.NewGuid().ToString();
seen.Add(encounter.EncounterDefinition.internalId);
changed = true;
}
}
if(changed) {
UnityEditor.EditorUtility.SetDirty(this);
}
}
#endif
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e480a30007b949679b8ca1e0e6088675
timeCreated: 1776507230
@@ -0,0 +1,8 @@
using UnityEngine;
namespace Jovian.EncounterSystem {
[CreateAssetMenu(fileName = "EncountersCollection", menuName = "Jovian/Encounter System/Encounters Collection", order = 0)]
public class EncountersCollection : ScriptableObject {
public EncounterTable[] encounterTables;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 96ab08e2592347f68b8ad2e6e8d45187
timeCreated: 1776506926
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
public interface IEncounter {
EncounterDefinition EncounterDefinition { get; set; }
EncounterProperties EncounterProperties { get; set; }
EncounterVisuals EncounterVisuals { get; set; }
EncounterDialogOptionSet EncounterDialogOptionSet { get; set; }
}
/// <summary>Default concrete encounter. Extend via a new <see cref="IEncounterKind"/>, not by subclassing.</summary>
[Serializable]
public class Encounter : IEncounter {
[field: SerializeField] public EncounterDefinition EncounterDefinition { get; set; }
[field: SerializeField] public EncounterProperties EncounterProperties { get; set; }
[field: SerializeField] public EncounterVisuals EncounterVisuals { get; set; }
[field: SerializeField] public EncounterDialogOptionSet EncounterDialogOptionSet { get; set; }
}
[Serializable]
public class EncounterDefinition {
/// <summary>Stable GUID assigned at creation. Never edit manually.</summary>
[HideInInspector]
public string internalId = Guid.NewGuid().ToString();
public string id;
public string name;
public string description;
[field: SerializeReference, SubclassSelector] public IEncounterKind Kind { get; set; }
}
[Serializable]
public class EncounterDialogOption {
public DialogLineRef text;
[SerializeReference, SubclassSelector]
public List<IEncounterEvent> events;
}
[Serializable]
public class EncounterVisuals {
public Sprite icon;
public Color encounterColor;
public Sprite encounterArt;
}
[Serializable]
public class EncounterProperties {
public int difficulty;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4d9c61fd5089459d8ef34cbbde0666b5
timeCreated: 1776506880
@@ -0,0 +1,27 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Data-only dialog option side effect. Handlers are registered on <see cref="EncounterResolver"/>.</summary>
public interface IEncounterEvent {
}
[Serializable]
public class ChainToEncounterEvent : IEncounterEvent {
public string nextEncounterId;
}
[Serializable]
public class StartCombatEvent : IEncounterEvent {
public string combatEncounterId;
}
[Serializable]
public class LogEvent : IEncounterEvent {
public string message;
}
[Serializable]
public class GiveRewardEvent : IEncounterEvent {
public Reward reward;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e42b3f5f74428d944a68a320a821f0c9
@@ -0,0 +1,12 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Polymorphic payload on an <see cref="IEncounter"/>. Add a new kind by implementing
/// this interface; the SubclassSelector drawer surfaces it automatically.</summary>
public interface IEncounterKind { }
[Serializable]
public class QuestKind : IEncounterKind {
public EncounterLink nextEncounter;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28cc80b36b63e6e44b7f1cfb6c57bf62
@@ -0,0 +1,7 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Polymorphic payload on a <see cref="Reward"/>. Add a new kind by implementing this interface.</summary>
public interface IRewardKind {
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2793c02adec79d4088031ab399c16e1
@@ -0,0 +1,19 @@
{
"name": "Jovian.EncounterSystem",
"rootNamespace": "Jovian.EncounterSystem",
"references": [
"UnityEngine.UI",
"Unity.TextMeshPro",
"Unity.Addressables",
"Unity.ResourceManager"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0452817d1bdb1084da85c56a64179c01
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
public enum QuestLogEventType {
Started,
Advanced,
Completed
}
[Serializable]
public class QuestLogEntry {
public QuestLogEventType type;
public string encounterInternalId;
public string encounterName;
public string fromEncounterName;
}
/// <summary>
/// Chronological, serialisable record of quest events. Subscribes at construction —
/// build before any encounter fires or early entries will be missed.
/// </summary>
public class QuestLog {
private readonly List<QuestLogEntry> entries = new();
public IReadOnlyList<QuestLogEntry> Entries => entries;
public QuestLog(QuestProgress progress) {
progress.QuestStarted += quest => Record(QuestLogEventType.Started, null, quest);
progress.QuestAdvanced += (from, to) => Record(QuestLogEventType.Advanced, from, to);
progress.QuestCompleted += quest => Record(QuestLogEventType.Completed, null, quest);
}
public List<QuestLogEntry> CreateSnapshot() {
return new List<QuestLogEntry>(entries);
}
public void Restore(IEnumerable<QuestLogEntry> saved) {
entries.Clear();
if(saved == null) {
return;
}
entries.AddRange(saved);
}
public IEnumerable<string> ResolvedEncounterIds() {
for(int i = 0; i < entries.Count; i++) {
var id = entries[i].encounterInternalId;
if(!string.IsNullOrEmpty(id)) {
yield return id;
}
}
}
private void Record(QuestLogEventType type, IEncounter from, IEncounter to) {
entries.Add(new QuestLogEntry {
type = type,
encounterInternalId = to?.EncounterDefinition?.internalId,
encounterName = to?.EncounterDefinition?.name,
fromEncounterName = from?.EncounterDefinition?.name
});
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 74f83e6449bec1847bcb25cb5398a682
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
/// <summary>
/// Gated quest progression. Encounter E is gated iff some QuestKind encounter P has
/// <c>P.nextEncounter == E</c> and P hasn't been resolved. Predecessor map is built once at
/// construction; rolling and advancement are O(1).
/// </summary>
public class QuestProgress {
private readonly HashSet<string> resolvedIds;
private readonly Dictionary<string, IEncounter> predecessorOf;
public event Action<IEncounter> QuestStarted;
public event Action<IEncounter, IEncounter> QuestAdvanced;
public event Action<IEncounter> QuestCompleted;
public IReadOnlyCollection<string> ResolvedIds => resolvedIds;
public QuestProgress(EncountersCollection encountersCollection) {
var capacity = EstimateEncounterCount(encountersCollection);
resolvedIds = new HashSet<string>(capacity);
predecessorOf = new Dictionary<string, IEncounter>(capacity);
IndexQuests(encountersCollection);
}
private static int EstimateEncounterCount(EncountersCollection collection) {
if(collection?.encounterTables == null) {
return 0;
}
var total = 0;
for(int i = 0; i < collection.encounterTables.Length; i++) {
var table = collection.encounterTables[i];
if(table?.encounters != null) {
total += table.encounters.Count;
}
}
return total;
}
public bool IsGated(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return false;
}
if(!predecessorOf.TryGetValue(id, out var predecessor)) {
return false;
}
return !resolvedIds.Contains(predecessor.EncounterDefinition.internalId);
}
public void OnEncounterTriggered(IEncounter encounter) {
if(encounter?.EncounterDefinition.Kind is not QuestKind questKind) {
return;
}
var id = encounter.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id) || !resolvedIds.Add(id)) {
return;
}
var next = questKind.nextEncounter.Resolve();
var hasPredecessor = predecessorOf.TryGetValue(id, out var predecessor);
if(!hasPredecessor) {
QuestStarted?.Invoke(encounter);
}
else {
QuestAdvanced?.Invoke(predecessor, encounter);
}
if(next == null) {
QuestCompleted?.Invoke(encounter);
}
}
public void LoadResolvedIds(IEnumerable<string> ids) {
resolvedIds.Clear();
if(ids == null) {
return;
}
foreach(var id in ids) {
if(!string.IsNullOrEmpty(id)) {
resolvedIds.Add(id);
}
}
}
private void IndexQuests(EncountersCollection collection) {
if(collection?.encounterTables == null) {
return;
}
for(int t = 0; t < collection.encounterTables.Length; t++) {
var table = collection.encounterTables[t];
if(table?.encounters == null) {
continue;
}
for(int i = 0; i < table.encounters.Count; i++) {
var encounter = table.encounters[i];
if(encounter?.EncounterDefinition?.Kind is not QuestKind questKind) {
continue;
}
var next = questKind.nextEncounter.Resolve();
if(next == null) {
continue;
}
var nextId = next.EncounterDefinition?.internalId;
if(!string.IsNullOrEmpty(nextId)) {
predecessorOf[nextId] = encounter;
}
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2eacdbd0e462b9b46b7f3cce64d26765
@@ -0,0 +1,13 @@
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Reusable reward asset referenced by <see cref="GiveRewardEvent"/>.</summary>
[CreateAssetMenu(fileName = "Reward", menuName = "Jovian/Encounter System/Reward", order = 3)]
public class Reward : ScriptableObject {
public string id;
public string displayName;
[SerializeReference, SubclassSelector]
public IRewardKind kind;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ce47d4bfb319877429589295ac214255
@@ -0,0 +1,9 @@
using System;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Renders a concrete-type picker dropdown for a <c>[SerializeReference]</c> field.</summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class SubclassSelectorAttribute : PropertyAttribute {
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ef3578112ccfa3447b74712009145c75
@@ -0,0 +1,19 @@
{
"name": "com.jovian.encounter-system",
"version": "0.1.0",
"displayName": "Jovian Encounter System",
"description": "Data-driven encounter authoring with polymorphic encounter kinds, dialog options with designer-authored events, cross-table quest chaining, and gated quest progression.",
"unity": "2022.3",
"dependencies": {
"com.unity.addressables": "1.21.0"
},
"keywords": [
"encounter",
"quest",
"dialog",
"rpg"
],
"author": {
"name": "Jovian"
}
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 75b61f91daea15646839c76fcba9a1d7
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: