removed a ton of xml comments and such

This commit is contained in:
Sebastian Bularca
2026-04-19 12:46:26 +02:00
parent 8ce041e2d8
commit d05641c979
25 changed files with 221 additions and 367 deletions

View File

@@ -3,50 +3,39 @@ using UnityEditor;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem.Editor { namespace Jovian.EncounterSystem.Editor {
/// <summary> /// <summary>Id dropdown (library inherited from the owning asset's <c>library</c> field) + inline fallback + resolved preview.</summary>
/// Drawer for <see cref="DialogLineRef"/>. Shows the library + id picker on one row, the inline
/// fallback text area underneath, and a resolved preview below that. Preserves WYSIWYG — the
/// final text is always visible.
/// </summary>
[CustomPropertyDrawer(typeof(DialogLineRef))] [CustomPropertyDrawer(typeof(DialogLineRef))]
public class DialogLineRefDrawer : PropertyDrawer { public class DialogLineRefDrawer : PropertyDrawer {
private const string NonePlaceholder = "<none>"; private const string NonePlaceholder = "<none>";
private const string EmptyLibraryPlaceholder = "<assign a library first>"; private const string EmptyLibraryPlaceholder = "<set library on the parent asset>";
private const float PreviewHeight = 32f; private const float PreviewHeight = 32f;
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
var libraryProp = property.FindPropertyRelative("library");
var idProp = property.FindPropertyRelative("id"); var idProp = property.FindPropertyRelative("id");
var inlineProp = property.FindPropertyRelative("inlineText"); var inlineProp = property.FindPropertyRelative("inlineText");
var library = ResolveLibrary(property);
EditorGUI.BeginProperty(position, label, property); EditorGUI.BeginProperty(position, label, property);
var lineHeight = EditorGUIUtility.singleLineHeight; var lineHeight = EditorGUIUtility.singleLineHeight;
var spacing = EditorGUIUtility.standardVerticalSpacing; var spacing = EditorGUIUtility.standardVerticalSpacing;
var libraryRect = new Rect(position.x, position.y, position.width, lineHeight); var idRect = new Rect(position.x, position.y, position.width, lineHeight);
EditorGUI.BeginChangeCheck(); DrawIdPicker(idRect, library, idProp, label);
EditorGUI.PropertyField(libraryRect, libraryProp, label);
var libraryChanged = EditorGUI.EndChangeCheck();
using(new EditorGUI.IndentLevelScope()) { var inlineRect = new Rect(
var idRect = new Rect(position.x, position.y + lineHeight + spacing, position.width, lineHeight); position.x,
DrawIdPicker(idRect, libraryProp, idProp, libraryChanged); position.y + lineHeight + spacing,
position.width,
lineHeight * 2);
EditorGUI.PropertyField(inlineRect, inlineProp, new GUIContent("Inline"));
var inlineRect = new Rect( var previewRect = new Rect(
position.x, position.x,
position.y + (lineHeight + spacing) * 2, position.y + lineHeight + spacing + lineHeight * 2 + spacing,
position.width, position.width,
lineHeight * 2); PreviewHeight);
EditorGUI.PropertyField(inlineRect, inlineProp, new GUIContent("Inline")); DrawPreview(previewRect, library, idProp, inlineProp);
var previewRect = new Rect(
position.x,
position.y + (lineHeight + spacing) * 2 + lineHeight * 2 + spacing,
position.width,
PreviewHeight);
DrawPreview(previewRect, libraryProp, idProp, inlineProp);
}
EditorGUI.EndProperty(); EditorGUI.EndProperty();
} }
@@ -54,18 +43,19 @@ namespace Jovian.EncounterSystem.Editor {
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var lineHeight = EditorGUIUtility.singleLineHeight; var lineHeight = EditorGUIUtility.singleLineHeight;
var spacing = EditorGUIUtility.standardVerticalSpacing; var spacing = EditorGUIUtility.standardVerticalSpacing;
// library + id + (inline 2 lines) + preview // id + (inline 2 lines) + preview
return (lineHeight + spacing) * 2 + lineHeight * 2 + spacing + PreviewHeight; return lineHeight + spacing + lineHeight * 2 + spacing + PreviewHeight;
} }
private static void DrawIdPicker(Rect rect, SerializedProperty libraryProp, SerializedProperty idProp, bool libraryChanged) { private static DialogLineLibrary ResolveLibrary(SerializedProperty property) {
var library = libraryProp.objectReferenceValue as DialogLineLibrary; 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) { if(library == null || library.lines == null || library.lines.Count == 0) {
using(new EditorGUI.DisabledScope(true)) { using(new EditorGUI.DisabledScope(true)) {
EditorGUI.Popup(rect, "Id", 0, new[] { EmptyLibraryPlaceholder }); EditorGUI.Popup(rect, label.text, 0, new[] { EmptyLibraryPlaceholder });
}
if(libraryChanged) {
idProp.stringValue = string.Empty;
} }
return; return;
} }
@@ -82,24 +72,18 @@ namespace Jovian.EncounterSystem.Editor {
var id = line?.id ?? string.Empty; var id = line?.id ?? string.Empty;
ids[i + 1] = id; ids[i + 1] = id;
names[i + 1] = string.IsNullOrEmpty(id) ? $"<unnamed {i}>" : id; names[i + 1] = string.IsNullOrEmpty(id) ? $"<unnamed {i}>" : id;
if(!libraryChanged && id == idProp.stringValue && !string.IsNullOrEmpty(id)) { if(id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
currentIndex = i + 1; currentIndex = i + 1;
} }
} }
if(libraryChanged) { var newIndex = EditorGUI.Popup(rect, label.text, currentIndex, names);
idProp.stringValue = string.Empty;
currentIndex = 0;
}
var newIndex = EditorGUI.Popup(rect, "Id", currentIndex, names);
if(newIndex != currentIndex) { if(newIndex != currentIndex) {
idProp.stringValue = ids[newIndex]; idProp.stringValue = ids[newIndex];
} }
} }
private static void DrawPreview(Rect rect, SerializedProperty libraryProp, SerializedProperty idProp, SerializedProperty inlineProp) { private static void DrawPreview(Rect rect, DialogLineLibrary library, SerializedProperty idProp, SerializedProperty inlineProp) {
var library = libraryProp.objectReferenceValue as DialogLineLibrary;
var id = idProp.stringValue; var id = idProp.stringValue;
var inline = inlineProp.stringValue; var inline = inlineProp.stringValue;

View File

@@ -8,11 +8,7 @@ using UnityEngine;
using UnityEngine.UIElements; using UnityEngine.UIElements;
namespace Jovian.EncounterSystem.Editor { namespace Jovian.EncounterSystem.Editor {
/// <summary> /// <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>
/// 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 { public class EncounterBrowserWindow : EditorWindow {
private const string AllKinds = "All"; private const string AllKinds = "All";
@@ -23,13 +19,12 @@ namespace Jovian.EncounterSystem.Editor {
} }
private readonly List<Record> allRecords = new(); private readonly List<Record> allRecords = new();
private List<Record> filteredRecords = new();
private string searchText = string.Empty; private string searchText = string.Empty;
private string kindFilter = AllKinds; private string kindFilter = AllKinds;
private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new(); private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new();
private ListView listView; private TreeView treeView;
private VisualElement detailPane; private VisualElement detailPane;
private ToolbarMenu kindDropdown; private ToolbarMenu kindDropdown;
@@ -78,15 +73,15 @@ namespace Jovian.EncounterSystem.Editor {
split.style.flexGrow = 1f; split.style.flexGrow = 1f;
rootVisualElement.Add(split); rootVisualElement.Add(split);
listView = new ListView { treeView = new TreeView {
makeItem = MakeRow, makeItem = MakeRow,
bindItem = BindRow, bindItem = BindRow,
fixedItemHeight = 22, fixedItemHeight = 22,
selectionType = SelectionType.Single selectionType = SelectionType.Single
}; };
listView.selectionChanged += OnSelectionChanged; treeView.selectionChanged += OnSelectionChanged;
listView.style.flexGrow = 1f; treeView.style.flexGrow = 1f;
split.Add(listView); split.Add(treeView);
detailPane = new ScrollView(ScrollViewMode.Vertical) { detailPane = new ScrollView(ScrollViewMode.Vertical) {
style = { paddingLeft = 8, paddingTop = 8, paddingRight = 8, flexGrow = 1f } style = { paddingLeft = 8, paddingTop = 8, paddingRight = 8, flexGrow = 1f }
@@ -134,7 +129,7 @@ namespace Jovian.EncounterSystem.Editor {
} }
private void BindRow(VisualElement element, int index) { private void BindRow(VisualElement element, int index) {
var record = filteredRecords[index]; var record = treeView.GetItemDataForIndex<Record>(index);
var label = element.Q<Label>("row-label"); var label = element.Q<Label>("row-label");
var badge = element.Q<VisualElement>("issue-badge"); var badge = element.Q<VisualElement>("issue-badge");
@@ -222,15 +217,73 @@ namespace Jovian.EncounterSystem.Editor {
} }
private void ApplyFilter() { private void ApplyFilter() {
filteredRecords = allRecords.Where(Matches).ToList(); var filtered = allRecords.Where(Matches).ToList();
if(listView != null) { var items = BuildTreeItems(filtered);
listView.itemsSource = filteredRecords; if(treeView != null) {
listView.Rebuild(); treeView.SetRootItems(items);
listView.ClearSelection(); treeView.Rebuild();
treeView.ClearSelection();
treeView.ExpandAll();
} }
ShowEmptyDetail(); ShowEmptyDetail();
} }
private static List<TreeViewItemData<Record>> BuildTreeItems(List<Record> records) {
var byEncounter = new Dictionary<IEncounter, Record>();
foreach(var r in records) {
if(r.encounter != null) {
byEncounter[r.encounter] = r;
}
}
// Any encounter that is a `nextEncounter` target of another record in the filtered set
// becomes a non-root (rendered as a child), not a top-level item.
var nonRoot = new HashSet<IEncounter>();
foreach(var r in records) {
if(r.encounter?.Kind is not QuestKind quest) {
continue;
}
var next = quest.nextEncounter.Resolve();
if(next != null && byEncounter.ContainsKey(next)) {
nonRoot.Add(next);
}
}
var result = new List<TreeViewItemData<Record>>();
var uid = 0;
foreach(var r in records) {
if(r.encounter != null && nonRoot.Contains(r.encounter)) {
continue;
}
var children = BuildChainChildren(r, byEncounter, ref uid);
result.Add(new TreeViewItemData<Record>(uid++, r, children));
}
return result;
}
private static List<TreeViewItemData<Record>> BuildChainChildren(Record root, Dictionary<IEncounter, Record> byEncounter, ref int uid) {
var children = new List<TreeViewItemData<Record>>();
if(root.encounter?.Kind is not QuestKind) {
return children;
}
var current = root.encounter;
var visited = new HashSet<IEncounter> { current };
while(current?.Kind is QuestKind quest) {
var next = quest.nextEncounter.Resolve();
if(next == null || !visited.Add(next)) {
break;
}
if(!byEncounter.TryGetValue(next, out var nextRecord)) {
break;
}
children.Add(new TreeViewItemData<Record>(uid++, nextRecord));
current = next;
}
return children;
}
private bool Matches(Record record) { private bool Matches(Record record) {
var kindName = record.encounter?.Kind?.GetType().Name ?? string.Empty; var kindName = record.encounter?.Kind?.GetType().Name ?? string.Empty;
if(kindFilter != AllKinds && kindName != kindFilter) { if(kindFilter != AllKinds && kindName != kindFilter) {

View File

@@ -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;
}
}
}

View File

@@ -3,11 +3,7 @@ using UnityEditor;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem.Editor { namespace Jovian.EncounterSystem.Editor {
/// <summary> /// <summary>Label list elements with the encounter id (fallback: name, then default).</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))] [CustomPropertyDrawer(typeof(Encounter))]
public class EncounterDrawer : PropertyDrawer { public class EncounterDrawer : PropertyDrawer {
private const string DefinitionBackingField = "<EncounterDefinition>k__BackingField"; private const string DefinitionBackingField = "<EncounterDefinition>k__BackingField";

View File

@@ -3,11 +3,7 @@ using UnityEditor;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem.Editor { namespace Jovian.EncounterSystem.Editor {
/// <summary> /// <summary>Table object-field + encounter dropdown picker. Changing tables clears the id.</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))] [CustomPropertyDrawer(typeof(EncounterLink))]
public class EncounterLinkDrawer : PropertyDrawer { public class EncounterLinkDrawer : PropertyDrawer {
private const string NonePlaceholder = "<none>"; private const string NonePlaceholder = "<none>";

View File

@@ -4,18 +4,11 @@ using UnityEditor;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem.Editor { namespace Jovian.EncounterSystem.Editor {
/// <summary>Severity of a <see cref="ValidationIssue"/>.</summary>
public enum ValidationSeverity { public enum ValidationSeverity {
Warning, Warning,
Error 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 class ValidationIssue {
public Object asset; public Object asset;
public Encounter encounter; public Encounter encounter;
@@ -24,13 +17,8 @@ namespace Jovian.EncounterSystem.Editor {
public string message; public string message;
} }
/// <summary> /// <summary>Project-wide scan of encounter tables and rewards. Runs on demand, no caching.</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 { public static class EncounterValidator {
/// <summary>Walk every encounter table and reward asset, returning all issues found.</summary>
public static List<ValidationIssue> ValidateProject() { public static List<ValidationIssue> ValidateProject() {
var issues = new List<ValidationIssue>(); var issues = new List<ValidationIssue>();
@@ -48,7 +36,6 @@ namespace Jovian.EncounterSystem.Editor {
return 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) { public static List<ValidationIssue> ValidateEncounter(EncounterTable table, int index) {
var issues = new List<ValidationIssue>(); var issues = new List<ValidationIssue>();
if(table?.encounters == null || index < 0 || index >= table.encounters.Count) { if(table?.encounters == null || index < 0 || index >= table.encounters.Count) {
@@ -197,38 +184,39 @@ namespace Jovian.EncounterSystem.Editor {
} }
private static void ValidateDialogLineRef(EncounterDialogOptionSet optionSet, Encounter encounter, string path, DialogLineRef lineRef, List<ValidationIssue> issues) { private static void ValidateDialogLineRef(EncounterDialogOptionSet optionSet, Encounter encounter, string path, DialogLineRef lineRef, List<ValidationIssue> issues) {
var hasLibrary = lineRef.library != null; var library = optionSet.library;
var hasId = !string.IsNullOrEmpty(lineRef.id); var hasId = !string.IsNullOrEmpty(lineRef.id);
var hasInline = !string.IsNullOrEmpty(lineRef.inlineText); var hasInline = !string.IsNullOrEmpty(lineRef.inlineText);
if(!hasLibrary && !hasInline) { if(!hasId && !hasInline) {
issues.Add(new ValidationIssue { issues.Add(new ValidationIssue {
asset = optionSet, asset = optionSet,
encounter = encounter, encounter = encounter,
path = path, path = path,
severity = ValidationSeverity.Warning, severity = ValidationSeverity.Warning,
message = "Dialog line is empty (no library reference and no inline text)." message = "Dialog line is empty (no id and no inline text)."
}); });
return; return;
} }
if(hasLibrary && hasId && string.IsNullOrEmpty(lineRef.library.Resolve(lineRef.id))) { if(hasId && library == null) {
issues.Add(new ValidationIssue { issues.Add(new ValidationIssue {
asset = optionSet, asset = optionSet,
encounter = encounter, encounter = encounter,
path = path, path = path,
severity = ValidationSeverity.Error, severity = ValidationSeverity.Error,
message = $"DialogLineRef id '{lineRef.id}' not found in library '{lineRef.library.name}'." message = $"DialogLineRef references id '{lineRef.id}' but the option set has no library assigned."
}); });
return;
} }
if(hasLibrary && !hasId && !hasInline) { if(hasId && library != null && string.IsNullOrEmpty(library.Resolve(lineRef.id))) {
issues.Add(new ValidationIssue { issues.Add(new ValidationIssue {
asset = optionSet, asset = optionSet,
encounter = encounter, encounter = encounter,
path = path, path = path,
severity = ValidationSeverity.Warning, severity = ValidationSeverity.Error,
message = "Library assigned but no id picked; will fall back to empty inline text." message = $"DialogLineRef id '{lineRef.id}' not found in library '{library.name}'."
}); });
} }
} }
@@ -271,7 +259,6 @@ namespace Jovian.EncounterSystem.Editor {
} }
} }
/// <summary>Menu-driven runner that prints the validator report to the console with click-through asset pings.</summary>
public static class EncounterValidatorMenu { public static class EncounterValidatorMenu {
[MenuItem("Jovian/Encounters/Validate All")] [MenuItem("Jovian/Encounters/Validate All")]
public static void ValidateAll() { public static void ValidateAll() {

View File

@@ -6,16 +6,7 @@ using UnityEditor;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem.Editor { namespace Jovian.EncounterSystem.Editor {
/// <summary> /// <summary>Concrete-type dropdown for <c>[SerializeReference]</c> fields, arrays, and list elements.</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))] [CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
public class SubclassSelectorDrawer : PropertyDrawer { public class SubclassSelectorDrawer : PropertyDrawer {
private static readonly Dictionary<Type, Type[]> TypeCache = new(); private static readonly Dictionary<Type, Type[]> TypeCache = new();

View File

@@ -3,30 +3,19 @@ using System.Collections.Generic;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary>One dialog line — an id/text pair in a <see cref="DialogLineLibrary"/>.</summary>
[Serializable] [Serializable]
public class DialogLine { public class DialogLine {
/// <summary>Stable key referenced by <see cref="DialogLineRef"/> (e.g. "common.farewell").</summary>
public string id; public string id;
[TextArea(2, 6)] public string text;
/// <summary>The actual text shown to the player.</summary>
[TextArea(2, 6)]
public string text;
} }
/// <summary> /// <summary>Flat registry of reusable dialog lines. Referenced via <see cref="DialogLineRef"/>.</summary>
/// Single-asset registry of reusable dialog lines. One library file holds many lines; dialog
/// options reference them by id via <see cref="DialogLineRef"/>. Split into multiple libraries
/// (e.g. CommonLines, TownDialogue) only when a single file becomes unwieldy.
/// </summary>
[CreateAssetMenu(fileName = "DialogLineLibrary", menuName = "Jovian/Encounter System/Dialog Line Library", order = 4)] [CreateAssetMenu(fileName = "DialogLineLibrary", menuName = "Jovian/Encounter System/Dialog Line Library", order = 4)]
public class DialogLineLibrary : ScriptableObject { public class DialogLineLibrary : ScriptableObject {
/// <summary>All lines in the library.</summary>
public List<DialogLine> lines = new(); public List<DialogLine> lines = new();
private Dictionary<string, string> cache; private Dictionary<string, string> cache;
/// <summary>Return the text for <paramref name="id"/>, or <c>null</c> if not found.</summary>
public string Resolve(string id) { public string Resolve(string id) {
if(string.IsNullOrEmpty(id)) { if(string.IsNullOrEmpty(id)) {
return null; return null;
@@ -36,8 +25,6 @@ namespace Jovian.EncounterSystem {
return cache.TryGetValue(id, out var text) ? text : null; return cache.TryGetValue(id, out var text) ? text : null;
} }
/// <summary>Force the next <see cref="Resolve"/> call to rebuild the id → text cache.
/// Called automatically from <c>OnValidate</c> after inspector edits.</summary>
public void InvalidateCache() { public void InvalidateCache() {
cache = null; cache = null;
} }

View File

@@ -2,28 +2,13 @@ using System;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Looks up <see cref="id"/> in the library passed to <see cref="Resolve"/>; falls back to <see cref="inlineText"/>.</summary>
/// Reference to a dialog line. Looks up <see cref="id"/> inside <see cref="library"/> when both
/// are set; otherwise falls back to <see cref="inlineText"/>. This lets designers prototype
/// one-off lines inline and promote common ones to the shared library later without changing
/// the field's type.
/// </summary>
[Serializable] [Serializable]
public struct DialogLineRef { public struct DialogLineRef {
/// <summary>Shared library to resolve <see cref="id"/> against. Optional.</summary>
public DialogLineLibrary library;
/// <summary>Line id inside <see cref="library"/>. Ignored if <see cref="library"/> is null.</summary>
public string id; public string id;
[TextArea(2, 6)] public string inlineText;
/// <summary>Fallback text used when the library reference is missing or fails to resolve. public string Resolve(DialogLineLibrary library) {
/// Authored directly in the inspector for one-off lines.</summary>
[TextArea(2, 6)]
public string inlineText;
/// <summary>Resolve to final text. Returns <see cref="inlineText"/> if no library reference
/// resolves, or <c>null</c> if both paths yield nothing.</summary>
public string Resolve() {
if(library != null && !string.IsNullOrEmpty(id)) { if(library != null && !string.IsNullOrEmpty(id)) {
var text = library.Resolve(id); var text = library.Resolve(id);
if(!string.IsNullOrEmpty(text)) { if(!string.IsNullOrEmpty(text)) {

View File

@@ -1,11 +1,6 @@
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Per-resolution scratch object passed to every event handler. Extend with fields as handlers need them.</summary>
/// Per-resolution state passed into every event handler invoked by <see cref="EncounterResolver.Resolve"/>.
/// Holds "who/what is currently being resolved" — not long-lived services. Extend with additional
/// fields (party, selected option, acting character, …) as handlers need them.
/// </summary>
public class EncounterContext { public class EncounterContext {
/// <summary>The encounter whose dialog option fired this resolution.</summary>
public IEncounter CurrentEncounter { get; } public IEncounter CurrentEncounter { get; }
public EncounterContext(IEncounter currentEncounter) { public EncounterContext(IEncounter currentEncounter) {

View File

@@ -2,17 +2,14 @@ using System.Collections.Generic;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Reusable dialog option list. Asset file auto-renames to match <see cref="id"/>.</summary>
/// Reusable asset containing the dialog options shown for an encounter. Stored as an asset so a
/// single set can be shared across encounters when the same choices apply. When <see cref="id"/>
/// changes the asset file auto-renames to match (editor-only).
/// </summary>
[CreateAssetMenu(fileName = "EncounterDialogOptionSet", menuName = "Jovian/Encounter System/Dialog Option Set", order = 2)] [CreateAssetMenu(fileName = "EncounterDialogOptionSet", menuName = "Jovian/Encounter System/Dialog Option Set", order = 2)]
public class EncounterDialogOptionSet : ScriptableObject { public class EncounterDialogOptionSet : ScriptableObject {
/// <summary>Designer-facing identifier for this option set. The asset file renames to match.</summary>
public string id; public string id;
/// <summary>Ordered list of options presented to the player.</summary> /// <summary>Shared library for every option's <see cref="EncounterDialogOption.text"/> lookup.</summary>
public DialogLineLibrary library;
public List<EncounterDialogOption> options; public List<EncounterDialogOption> options;
#if UNITY_EDITOR #if UNITY_EDITOR
@@ -21,7 +18,7 @@ namespace Jovian.EncounterSystem {
return; return;
} }
// Deferred — AssetDatabase calls are unsafe from OnValidate. // AssetDatabase calls are unsafe from OnValidate — defer.
UnityEditor.EditorApplication.delayCall += RenameToMatchId; UnityEditor.EditorApplication.delayCall += RenameToMatchId;
} }
@@ -31,10 +28,7 @@ namespace Jovian.EncounterSystem {
} }
var path = UnityEditor.AssetDatabase.GetAssetPath(this); var path = UnityEditor.AssetDatabase.GetAssetPath(this);
if(string.IsNullOrEmpty(path)) { if(string.IsNullOrEmpty(path) || name == id) {
return;
}
if(name == id) {
return; return;
} }

View File

@@ -1,21 +1,12 @@
using System; using System;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Cross-table reference. Rename-safe — the stored key is a GUID.</summary>
/// Cross-table reference to an <see cref="IEncounter"/>. Stores the owning
/// <see cref="EncounterTable"/> asset and the target encounter's <see cref="EncounterDefinition.internalId"/>.
/// Rename-safe because the stored key is a GUID.
/// </summary>
[Serializable] [Serializable]
public struct EncounterLink { public struct EncounterLink {
/// <summary>The table that owns the linked encounter.</summary>
public EncounterTable table; public EncounterTable table;
/// <summary>The target encounter's stable GUID (<see cref="EncounterDefinition.internalId"/>).</summary>
public string internalId; public string internalId;
/// <summary>Look up the referenced encounter, or <c>null</c> if the table is missing or the id
/// no longer exists in it.</summary>
public IEncounter Resolve() { public IEncounter Resolve() {
if(table == null || table.encounters == null || string.IsNullOrEmpty(internalId)) { if(table == null || table.encounters == null || string.IsNullOrEmpty(internalId)) {
return null; return null;

View File

@@ -3,11 +3,7 @@ using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Shared view scaffold — game code binds an <see cref="IEncounter"/> to these widgets.</summary>
/// Scene-side view scaffold for presenting an encounter. Provides wired-up references to the
/// common UI widgets (name/description/art/options container/submit button) so a game-specific
/// view controller can bind an <see cref="IEncounter"/> to them without duplicating boilerplate.
/// </summary>
public class EncounterReference : MonoBehaviour { public class EncounterReference : MonoBehaviour {
public TextMeshProUGUI encounterName; public TextMeshProUGUI encounterName;
public TextMeshProUGUI encounterDescription; public TextMeshProUGUI encounterDescription;

View File

@@ -4,39 +4,27 @@ using UnityEngine;
using UnityEngine.AddressableAssets; using UnityEngine.AddressableAssets;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>id → encounter cache. Editor auto-repopulates on asset changes; runtime must call <see cref="PopulateEncounters"/>.</summary>
/// Central lookup cache mapping <see cref="EncounterDefinition.internalId"/> to its owning
/// <see cref="IEncounter"/> across one or more <see cref="EncountersCollection"/> assets.
/// In editor the registry auto-populates via an asset postprocessor; at runtime call
/// <see cref="PopulateEncounters"/> explicitly after load.
/// </summary>
[CreateAssetMenu(fileName = "EncounterRegistry", menuName = "Jovian/Encounter System/Encounter Registry")] [CreateAssetMenu(fileName = "EncounterRegistry", menuName = "Jovian/Encounter System/Encounter Registry")]
public class EncounterRegistry : ScriptableObject { public class EncounterRegistry : ScriptableObject {
/// <summary>Collections whose encounters should be indexed.</summary>
public EncountersCollection[] encounterCollections = Array.Empty<EncountersCollection>(); public EncountersCollection[] encounterCollections = Array.Empty<EncountersCollection>();
private readonly Dictionary<string, IEncounter> encounters = new(); private readonly Dictionary<string, IEncounter> encounters = new();
/// <summary>The live id → encounter map.</summary>
public Dictionary<string, IEncounter> GetEncounters() => encounters; public Dictionary<string, IEncounter> GetEncounters() => encounters;
/// <summary>Add an encounter to the cache if its id isn't already present.</summary>
public void RegisterEncounter(IEncounter encounter) { public void RegisterEncounter(IEncounter encounter) {
encounters?.TryAdd(encounter?.EncounterDefinition?.internalId, encounter); encounters?.TryAdd(encounter?.EncounterDefinition?.internalId, encounter);
} }
/// <summary>Remove an encounter from the cache by its id.</summary>
public void UnregisterEncounter(IEncounter encounter) { public void UnregisterEncounter(IEncounter encounter) {
encounters.Remove(encounter.EncounterDefinition.internalId); encounters.Remove(encounter.EncounterDefinition.internalId);
} }
/// <summary>Clear the cache. Call before a full re-population to avoid stale entries.</summary>
public void ClearEncounters() { public void ClearEncounters() {
encounters.Clear(); encounters.Clear();
} }
/// <summary>Walk <see cref="encounterCollections"/> and register every encounter found.
/// Call <see cref="ClearEncounters"/> first if you need a clean state.</summary>
public void PopulateEncounters() { public void PopulateEncounters() {
foreach(var collection in encounterCollections) { foreach(var collection in encounterCollections) {
foreach(var encounter in collection.encounterTables) { foreach(var encounter in collection.encounterTables) {
@@ -49,11 +37,7 @@ namespace Jovian.EncounterSystem {
} }
#if UNITY_EDITOR #if UNITY_EDITOR
/// <summary> /// <summary>Rebuilds the registry (Addressables key "EncounterRegistry") on any asset import/move/delete.</summary>
/// Editor-time hook that keeps an <see cref="EncounterRegistry"/> asset (located via Addressables
/// key <c>"EncounterRegistry"</c>) in sync with asset edits: clears and repopulates on any asset
/// import/move/delete.
/// </summary>
public class EncounterRegister : UnityEditor.AssetPostprocessor { public class EncounterRegister : UnityEditor.AssetPostprocessor {
private static EncounterRegistry registryCache; private static EncounterRegistry registryCache;

View File

@@ -2,34 +2,20 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Dispatches <see cref="IEncounterEvent"/> instances to per-type handlers. Unknown types are skipped.</summary>
/// Dispatches <see cref="IEncounterEvent"/> instances (authored on dialog options) to per-type
/// handlers registered at composition time. Unknown event types are silently skipped.
/// </summary>
/// <remarks>
/// The resolver stores handlers keyed by concrete event <see cref="Type"/>. Registration wraps a
/// typed delegate in a closure that casts back to the concrete type — the cast is safe because
/// we only ever invoke the wrapped delegate via the dictionary lookup under the same key.
/// </remarks>
public class EncounterResolver { public class EncounterResolver {
private readonly Dictionary<Type, Action<IEncounterEvent, EncounterContext>> handlers = new(); private readonly Dictionary<Type, Action<IEncounterEvent, EncounterContext>> handlers = new();
/// <summary>Register a handler for a concrete event type. Replaces any prior registration.</summary>
/// <typeparam name="T">The event type to handle.</typeparam>
/// <param name="handler">The delegate invoked with the cast event and the resolution context.</param>
public void Register<T>(Action<T, EncounterContext> handler) where T : IEncounterEvent { 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); handlers[typeof(T)] = (evt, ctx) => handler((T)evt, ctx);
} }
/// <summary>Remove the handler registered for event type <typeparamref name="T"/>, if any.</summary>
public void Unregister<T>() where T : IEncounterEvent { public void Unregister<T>() where T : IEncounterEvent {
handlers.Remove(typeof(T)); handlers.Remove(typeof(T));
} }
/// <summary>Dispatch each event in <paramref name="events"/> to its registered handler, in order.
/// Null events and events with no registered handler are skipped.</summary>
/// <param name="events">The ordered event list (typically from an <see cref="EncounterDialogOption"/>).</param>
/// <param name="context">Per-resolution context passed to every handler.</param>
public void Resolve(IEnumerable<IEncounterEvent> events, EncounterContext context) { public void Resolve(IEnumerable<IEncounterEvent> events, EncounterContext context) {
if(events == null) { if(events == null) {
return; return;

View File

@@ -3,22 +3,11 @@ using System.Collections.Generic;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary>
/// A ScriptableObject asset holding a named list of encounters. Encounters inside the list are
/// authored via <c>[SerializeReference]</c> so different <see cref="IEncounter"/> implementations
/// and <see cref="IEncounterKind"/> payloads can coexist.
/// </summary>
[CreateAssetMenu(fileName = "EncounterTable", menuName = "Jovian/Encounter System/Encounter Table", order = 1)] [CreateAssetMenu(fileName = "EncounterTable", menuName = "Jovian/Encounter System/Encounter Table", order = 1)]
public class EncounterTable : ScriptableObject { public class EncounterTable : ScriptableObject {
/// <summary>Designer-facing table identifier (free-form).</summary>
public string id; public string id;
/// <summary>Ordered encounter list. Each element is a concrete <see cref="Encounter"/> whose
/// type-specific payload is carried by its <see cref="IEncounterKind"/> (set in the inspector
/// via the SubclassSelector dropdown on <see cref="Encounter.Kind"/>).</summary>
public List<Encounter> encounters; public List<Encounter> encounters;
/// <summary>Pick a uniformly random encounter, or <c>null</c> if the table is empty.</summary>
public IEncounter GetRandomEncounter() { public IEncounter GetRandomEncounter() {
if(encounters == null || encounters.Count == 0) { if(encounters == null || encounters.Count == 0) {
return null; return null;
@@ -27,9 +16,6 @@ namespace Jovian.EncounterSystem {
return encounters[UnityEngine.Random.Range(0, encounters.Count)]; return encounters[UnityEngine.Random.Range(0, encounters.Count)];
} }
/// <summary>Pick a uniformly random encounter whose runtime type matches <paramref name="type"/>,
/// or <c>null</c> if none match.</summary>
/// <param name="type">The concrete <see cref="IEncounter"/> runtime type to filter on.</param>
public IEncounter GetRandomEncounter(Type type) { public IEncounter GetRandomEncounter(Type type) {
if(encounters == null || encounters.Count == 0) { if(encounters == null || encounters.Count == 0) {
return null; return null;
@@ -43,10 +29,7 @@ namespace Jovian.EncounterSystem {
return null; return null;
} }
/// <summary>Pick a uniformly random encounter matching <paramref name="filter"/>, or <c>null</c> /// <summary>Random pick limited by a predicate. Used with <see cref="QuestProgress.IsGated"/> to exclude gated encounters.</summary>
/// if no element passes. Used by <see cref="QuestProgress.IsGated"/> integration to exclude
/// gated quest encounters from rolls.</summary>
/// <param name="filter">Predicate applied to each encounter before rolling.</param>
public IEncounter GetRandomEncounter(Predicate<IEncounter> filter) { public IEncounter GetRandomEncounter(Predicate<IEncounter> filter) {
if(encounters == null || encounters.Count == 0 || filter == null) { if(encounters == null || encounters.Count == 0 || filter == null) {
return GetRandomEncounter(); return GetRandomEncounter();

View File

@@ -1,13 +1,8 @@
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary>
/// Top-level asset that groups multiple <see cref="EncounterTable"/> references into a single
/// browsable unit. Pass one of these to <see cref="QuestProgress"/> to seed its gating graph.
/// </summary>
[CreateAssetMenu(fileName = "EncountersCollection", menuName = "Jovian/Encounter System/Encounters Collection", order = 0)] [CreateAssetMenu(fileName = "EncountersCollection", menuName = "Jovian/Encounter System/Encounters Collection", order = 0)]
public class EncountersCollection : ScriptableObject { public class EncountersCollection : ScriptableObject {
/// <summary>The tables grouped by this collection.</summary>
public EncounterTable[] encounterTables; public EncounterTable[] encounterTables;
} }
} }

View File

@@ -3,32 +3,15 @@ using System.Collections.Generic;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary>
/// Runtime contract for any encounter authored in the system.
/// An encounter aggregates designer-facing metadata, visuals, dialog, and a polymorphic
/// <see cref="IEncounterKind"/> payload that expresses its type-specific data.
/// </summary>
public interface IEncounter { public interface IEncounter {
/// <summary>Stable identity, display name, and designer description.</summary>
EncounterDefinition EncounterDefinition { get; set; } EncounterDefinition EncounterDefinition { get; set; }
/// <summary>Numeric configuration shared by every encounter kind.</summary>
EncounterProperties EncounterProperties { get; set; } EncounterProperties EncounterProperties { get; set; }
/// <summary>Visual assets displayed when the encounter is presented.</summary>
EncounterVisuals EncounterVisuals { get; set; } EncounterVisuals EncounterVisuals { get; set; }
/// <summary>Dialog options shown to the player, or <c>null</c> if the encounter has no dialog.</summary>
EncounterDialogOptionSet EncounterDialogOptionSet { get; set; } EncounterDialogOptionSet EncounterDialogOptionSet { get; set; }
/// <summary>Type-specific payload (combat, quest, social, ...). Authored via the SubclassSelector drawer.</summary>
IEncounterKind Kind { get; set; } IEncounterKind Kind { get; set; }
} }
/// <summary> /// <summary>Default concrete encounter. Extend via a new <see cref="IEncounterKind"/>, not by subclassing.</summary>
/// Default concrete encounter. Holds the shared fields and a polymorphic <see cref="IEncounterKind"/>.
/// Prefer adding new behaviour by introducing a new <see cref="IEncounterKind"/> rather than subclassing this type.
/// </summary>
[Serializable] [Serializable]
public class Encounter : IEncounter { public class Encounter : IEncounter {
[field: SerializeField] public EncounterDefinition EncounterDefinition { get; set; } [field: SerializeField] public EncounterDefinition EncounterDefinition { get; set; }
@@ -40,43 +23,25 @@ namespace Jovian.EncounterSystem {
public IEncounterKind Kind { get; set; } public IEncounterKind Kind { get; set; }
} }
/// <summary>
/// Designer-facing identity for an encounter. <see cref="internalId"/> is the stable GUID used for
/// cross-references (<see cref="EncounterLink"/>, quest progress, save data); <see cref="id"/> is a
/// human-readable slug; <see cref="name"/> and <see cref="description"/> are display strings.
/// </summary>
[Serializable] [Serializable]
public class EncounterDefinition { public class EncounterDefinition {
/// <summary>Stable GUID, assigned once at creation. Never edit manually.</summary> /// <summary>Stable GUID assigned at creation. Never edit manually.</summary>
[HideInInspector] [HideInInspector]
public string internalId = Guid.NewGuid().ToString(); public string internalId = Guid.NewGuid().ToString();
/// <summary>Designer-authored short slug (e.g. "goblin_ambush").</summary>
public string id; public string id;
/// <summary>Display name shown to the player.</summary>
public string name; public string name;
/// <summary>Flavour text shown when the encounter opens.</summary>
public string description; public string description;
} }
/// <summary>
/// A single choice presented to the player inside an <see cref="EncounterDialogOptionSet"/>.
/// Events fire in order when the option is picked and are dispatched through <see cref="EncounterResolver"/>.
/// </summary>
[Serializable] [Serializable]
public class EncounterDialogOption { public class EncounterDialogOption {
/// <summary>Option text shown in the UI. Resolved through a shared
/// <see cref="DialogLineLibrary"/> or falls back to inline text.</summary>
public DialogLineRef text; public DialogLineRef text;
/// <summary>Ordered events executed by the resolver when this option is chosen.</summary>
[SerializeReference, SubclassSelector] [SerializeReference, SubclassSelector]
public List<IEncounterEvent> events; public List<IEncounterEvent> events;
} }
/// <summary>Visual assets for an encounter.</summary>
[Serializable] [Serializable]
public class EncounterVisuals { public class EncounterVisuals {
public Sprite icon; public Sprite icon;
@@ -84,7 +49,6 @@ namespace Jovian.EncounterSystem {
public Sprite encounterArt; public Sprite encounterArt;
} }
/// <summary>Numeric/tuning configuration shared across every encounter kind.</summary>
[Serializable] [Serializable]
public class EncounterProperties { public class EncounterProperties {
public int difficulty; public int difficulty;

View File

@@ -1,38 +1,25 @@
using System; using System;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Data-only dialog option side effect. Handlers are registered on <see cref="EncounterResolver"/>.</summary>
/// Marker interface for designer-authored dialog option side effects. Events carry data only;
/// behaviour is registered on <see cref="EncounterResolver"/> and dispatched by concrete type.
/// Add a new event by creating a new <see cref="IEncounterEvent"/> implementation and registering
/// a handler for it with the resolver.
/// </summary>
public interface IEncounterEvent { public interface IEncounterEvent {
} }
/// <summary>Transitions the flow to another encounter identified by <see cref="nextEncounterId"/>.</summary>
[Serializable] [Serializable]
public class ChainToEncounterEvent : IEncounterEvent { public class ChainToEncounterEvent : IEncounterEvent {
public string nextEncounterId; public string nextEncounterId;
} }
/// <summary>Starts a combat encounter by id. Intended to be handled by the combat play mode.</summary>
[Serializable] [Serializable]
public class StartCombatEvent : IEncounterEvent { public class StartCombatEvent : IEncounterEvent {
public string combatEncounterId; public string combatEncounterId;
} }
/// <summary>Writes a line to whatever log sink the resolver's handler is configured with. Useful for debugging.</summary>
[Serializable] [Serializable]
public class LogEvent : IEncounterEvent { public class LogEvent : IEncounterEvent {
public string message; public string message;
} }
/// <summary>
/// Grants the referenced <see cref="Reward"/> asset to the party. The actual application is
/// handled by a game-side delegate registered on <see cref="EncounterResolver"/>. Add multiple
/// <see cref="GiveRewardEvent"/>s to a dialog option's event list to grant multiple rewards.
/// </summary>
[Serializable] [Serializable]
public class GiveRewardEvent : IEncounterEvent { public class GiveRewardEvent : IEncounterEvent {
public Reward reward; public Reward reward;

View File

@@ -1,31 +1,23 @@
using System; using System;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Polymorphic payload on an <see cref="IEncounter"/>. Add a new kind by implementing
/// Marker interface for the polymorphic payload of an <see cref="IEncounter"/>. /// this interface; the SubclassSelector drawer surfaces it automatically.</summary>
/// Each concrete kind carries its own designer-facing data. Behaviour lives in resolvers
/// and play modes — kinds are pure data. Add new kinds by creating a new <see cref="IEncounterKind"/>
/// implementation; the SubclassSelector drawer will surface it automatically.
/// </summary>
public interface IEncounterKind { public interface IEncounterKind {
} }
/// <summary>Combat encounter: triggers combat play mode with the specified enemy group.</summary>
[Serializable] [Serializable]
public class CombatKind : IEncounterKind { public class CombatKind : IEncounterKind {
public string enemyGroupId; public string enemyGroupId;
public string rewardTableId; public string rewardTableId;
} }
/// <summary>Quest encounter: a step in a quest chain. The <see cref="nextEncounter"/> link is what
/// <see cref="QuestProgress"/> walks to build the gated progression graph.</summary>
[Serializable] [Serializable]
public class QuestKind : IEncounterKind { public class QuestKind : IEncounterKind {
public EncounterLink nextEncounter; public EncounterLink nextEncounter;
public string questTitle; public string questTitle;
} }
/// <summary>Dialogue-driven encounter with an NPC and optional faction reputation impact.</summary>
[Serializable] [Serializable]
public class SocialKind : IEncounterKind { public class SocialKind : IEncounterKind {
public string npcId; public string npcId;
@@ -33,32 +25,27 @@ namespace Jovian.EncounterSystem {
public int reputationDelta; public int reputationDelta;
} }
/// <summary>Puzzle encounter gated by a skill check against <see cref="difficultyClass"/>.</summary>
[Serializable] [Serializable]
public class PuzzleKind : IEncounterKind { public class PuzzleKind : IEncounterKind {
public string puzzleId; public string puzzleId;
public int difficultyClass; public int difficultyClass;
} }
/// <summary>Exploration/discovery encounter gated by a perception check (<see cref="perceptionDC"/>).</summary>
[Serializable] [Serializable]
public class ExplorationKind : IEncounterKind { public class ExplorationKind : IEncounterKind {
public int perceptionDC; public int perceptionDC;
} }
/// <summary>Tutorial encounter driving a tutorial subsystem by id.</summary>
[Serializable] [Serializable]
public class TutorialKind : IEncounterKind { public class TutorialKind : IEncounterKind {
public string tutorialId; public string tutorialId;
} }
/// <summary>Environmental hazard that applies damage or a status without player choice.</summary>
[Serializable] [Serializable]
public class HazardKind : IEncounterKind { public class HazardKind : IEncounterKind {
public int damageAmount; public int damageAmount;
} }
/// <summary>Catch-all with no kind-specific data — useful while prototyping.</summary>
[Serializable] [Serializable]
public class OtherKind : IEncounterKind { public class OtherKind : IEncounterKind {
} }

View File

@@ -1,45 +1,32 @@
using System; using System;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Polymorphic payload on a <see cref="Reward"/>. Add a new kind by implementing this interface.</summary>
/// Marker interface for the polymorphic payload of a <see cref="Reward"/>.
/// Each concrete kind carries its own designer-facing data. Behaviour lives in a reward-applying
/// handler registered on <see cref="EncounterResolver"/> for <see cref="GiveRewardEvent"/> —
/// kinds are pure data. Add a new reward type by creating a new <see cref="IRewardKind"/>
/// implementation; the SubclassSelector drawer will surface it automatically.
/// </summary>
public interface IRewardKind { public interface IRewardKind {
} }
/// <summary>Grants an amount of a named currency (gold, silver, faction-specific scrip, ...).</summary>
[Serializable] [Serializable]
public class CurrencyRewardKind : IRewardKind { public class CurrencyRewardKind : IRewardKind {
public string currencyId; public string currencyId;
public int amount; public int amount;
} }
/// <summary>Grants one or more copies of an item identified by id.</summary>
[Serializable] [Serializable]
public class ItemRewardKind : IRewardKind { public class ItemRewardKind : IRewardKind {
public string itemId; public string itemId;
public int quantity; public int quantity;
} }
/// <summary>Grants experience points to the party or a specific recipient.</summary>
[Serializable] [Serializable]
public class ExperienceRewardKind : IRewardKind { public class ExperienceRewardKind : IRewardKind {
public int amount; public int amount;
} }
/// <summary>Unlocks something identified by id (recipe, area, journal entry, achievement, ...).
/// What is actually unlocked is decided by the game-side handler.</summary>
[Serializable] [Serializable]
public class UnlockableRewardKind : IRewardKind { public class UnlockableRewardKind : IRewardKind {
public string unlockableId; public string unlockableId;
} }
/// <summary>Catch-all with no kind-specific data — useful for prototyping or one-off rewards
/// whose semantics are carried by the <see cref="Reward.id"/> alone.</summary>
[Serializable] [Serializable]
public class OtherRewardKind : IRewardKind { public class OtherRewardKind : IRewardKind {
} }

View File

@@ -3,17 +3,12 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary>Tag for a quest log entry.</summary>
public enum QuestLogEventType { public enum QuestLogEventType {
Started, Started,
Advanced, Advanced,
Completed Completed
} }
/// <summary>
/// One chronological entry in the quest log. Names are cached alongside ids so a loaded save
/// can display the log even if the underlying encounter has since been renamed or removed.
/// </summary>
[Serializable] [Serializable]
public class QuestLogEntry { public class QuestLogEntry {
public QuestLogEventType type; public QuestLogEventType type;
@@ -23,34 +18,24 @@ namespace Jovian.EncounterSystem {
} }
/// <summary> /// <summary>
/// Chronological, serializable record of quest events. Subscribes to <see cref="QuestProgress"/> /// Chronological, serialisable record of quest events. Subscribes at construction —
/// events at construction time — build it before any encounter fires so nothing is missed. /// build before any encounter fires or early entries will be missed.
/// </summary> /// </summary>
/// <remarks>
/// This is the save payload for quest progression. On load, call <see cref="Restore"/> with the
/// saved entries and then <see cref="QuestProgress.LoadResolvedIds"/> with
/// <see cref="ResolvedEncounterIds"/> to rehydrate the gating set.
/// </remarks>
public class QuestLog { public class QuestLog {
private readonly List<QuestLogEntry> entries = new(); private readonly List<QuestLogEntry> entries = new();
/// <summary>The log in chronological order.</summary>
public IReadOnlyList<QuestLogEntry> Entries => entries; public IReadOnlyList<QuestLogEntry> Entries => entries;
/// <summary>Subscribe to <paramref name="progress"/>'s quest events; every fire appends an entry.</summary>
public QuestLog(QuestProgress progress) { public QuestLog(QuestProgress progress) {
progress.QuestStarted += quest => Record(QuestLogEventType.Started, null, quest); progress.QuestStarted += quest => Record(QuestLogEventType.Started, null, quest);
progress.QuestAdvanced += (from, to) => Record(QuestLogEventType.Advanced, from, to); progress.QuestAdvanced += (from, to) => Record(QuestLogEventType.Advanced, from, to);
progress.QuestCompleted += quest => Record(QuestLogEventType.Completed, null, quest); progress.QuestCompleted += quest => Record(QuestLogEventType.Completed, null, quest);
} }
/// <summary>Return a copy of the current entries suitable for serialization.</summary>
public List<QuestLogEntry> CreateSnapshot() { public List<QuestLogEntry> CreateSnapshot() {
return new List<QuestLogEntry>(entries); return new List<QuestLogEntry>(entries);
} }
/// <summary>Replace the current entries with those from a save. Pass the list straight from
/// the save payload.</summary>
public void Restore(IEnumerable<QuestLogEntry> saved) { public void Restore(IEnumerable<QuestLogEntry> saved) {
entries.Clear(); entries.Clear();
if(saved == null) { if(saved == null) {
@@ -60,8 +45,6 @@ namespace Jovian.EncounterSystem {
entries.AddRange(saved); entries.AddRange(saved);
} }
/// <summary>Enumerate the distinct encounter ids present in the log — what
/// <see cref="QuestProgress.LoadResolvedIds"/> needs on load.</summary>
public IEnumerable<string> ResolvedEncounterIds() { public IEnumerable<string> ResolvedEncounterIds() {
return entries return entries
.Select(entry => entry.encounterInternalId) .Select(entry => entry.encounterInternalId)

View File

@@ -3,39 +3,24 @@ using System.Collections.Generic;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>
/// Tracks which <see cref="QuestKind"/> encounters the party has resolved and gates any quest /// Gated quest progression. Encounter E is gated iff some QuestKind encounter P has
/// encounter whose predecessor hasn't fired yet. Consumers use <see cref="IsGated"/> to exclude /// <c>P.nextEncounter == E</c> and P hasn't been resolved. Predecessor map is built once at
/// blocked encounters from rolls, and <see cref="OnEncounterTriggered"/> to mark progress. /// construction; rolling and advancement are O(1).
/// </summary> /// </summary>
/// <remarks>
/// Gating rule: encounter <c>E</c> is gated iff some other quest encounter <c>P</c> has
/// <c>P.nextEncounter == E</c> and <c>P</c> hasn't been resolved yet. Build the predecessor map
/// once in <c>IndexQuests</c>; rolling and advancement are both O(1).
/// </remarks>
public class QuestProgress { public class QuestProgress {
private readonly HashSet<string> resolvedIds = new(); private readonly HashSet<string> resolvedIds = new();
private readonly Dictionary<string, IEncounter> predecessorOf = new(); private readonly Dictionary<string, IEncounter> predecessorOf = new();
/// <summary>Fires when a root quest encounter (no predecessor) is resolved.</summary>
public event Action<IEncounter> QuestStarted; public event Action<IEncounter> QuestStarted;
/// <summary>Fires when a chained quest encounter is resolved. Args: (previous, current).</summary>
public event Action<IEncounter, IEncounter> QuestAdvanced; public event Action<IEncounter, IEncounter> QuestAdvanced;
/// <summary>Fires when a quest encounter with no <c>nextEncounter</c> is resolved.</summary>
public event Action<IEncounter> QuestCompleted; public event Action<IEncounter> QuestCompleted;
/// <summary>Snapshot of every quest encounter id the party has resolved. Save this in the save file.</summary>
public IReadOnlyCollection<string> ResolvedIds => resolvedIds; public IReadOnlyCollection<string> ResolvedIds => resolvedIds;
/// <summary>Construct and index the quest graph from the given collection.</summary>
/// <param name="encountersCollection">The collection whose tables will be walked for quest chains.</param>
public QuestProgress(EncountersCollection encountersCollection) { public QuestProgress(EncountersCollection encountersCollection) {
IndexQuests(encountersCollection); IndexQuests(encountersCollection);
} }
/// <summary>Returns <c>true</c> if the encounter is currently blocked because its quest
/// predecessor hasn't been resolved yet.</summary>
public bool IsGated(IEncounter encounter) { public bool IsGated(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId; var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) { if(string.IsNullOrEmpty(id)) {
@@ -49,9 +34,6 @@ namespace Jovian.EncounterSystem {
return !resolvedIds.Contains(predecessor.EncounterDefinition.internalId); return !resolvedIds.Contains(predecessor.EncounterDefinition.internalId);
} }
/// <summary>Inform the progress service that an encounter was just shown to the party.
/// Non-quest encounters and already-resolved ones are no-ops. Fires one or two of the
/// <see cref="QuestStarted"/>/<see cref="QuestAdvanced"/>/<see cref="QuestCompleted"/> events.</summary>
public void OnEncounterTriggered(IEncounter encounter) { public void OnEncounterTriggered(IEncounter encounter) {
if(encounter?.Kind is not QuestKind questKind) { if(encounter?.Kind is not QuestKind questKind) {
return; return;
@@ -77,8 +59,6 @@ namespace Jovian.EncounterSystem {
} }
} }
/// <summary>Restore resolved quest ids from save data. Call after construction, before the
/// first <see cref="OnEncounterTriggered"/>.</summary>
public void LoadResolvedIds(IEnumerable<string> ids) { public void LoadResolvedIds(IEnumerable<string> ids) {
resolvedIds.Clear(); resolvedIds.Clear();
if(ids == null) { if(ids == null) {

View File

@@ -1,21 +1,12 @@
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Reusable reward asset referenced by <see cref="GiveRewardEvent"/>.</summary>
/// Authored reward asset. Designers create one file per reusable reward ("10 Gold", "Rusty Sword",
/// "Town Unlock: Riverfort") and reference it from <see cref="GiveRewardEvent"/> on dialog options.
/// The <see cref="kind"/> payload chooses the reward type; the enclosing asset carries identity
/// and presentation data shared across all types.
/// </summary>
[CreateAssetMenu(fileName = "Reward", menuName = "Jovian/Encounter System/Reward", order = 3)] [CreateAssetMenu(fileName = "Reward", menuName = "Jovian/Encounter System/Reward", order = 3)]
public class Reward : ScriptableObject { public class Reward : ScriptableObject {
/// <summary>Designer-facing short slug (e.g. "gold_10").</summary>
public string id; public string id;
/// <summary>Display name shown to the player when the reward is granted.</summary>
public string displayName; public string displayName;
/// <summary>Type-specific payload (currency, item, experience, ...). Authored via the SubclassSelector drawer.</summary>
[SerializeReference, SubclassSelector] [SerializeReference, SubclassSelector]
public IRewardKind kind; public IRewardKind kind;
} }

View File

@@ -2,11 +2,7 @@ using System;
using UnityEngine; using UnityEngine;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
/// <summary> /// <summary>Renders a concrete-type picker dropdown for a <c>[SerializeReference]</c> field.</summary>
/// Marker attribute instructing the editor to render a concrete-type picker for a
/// <c>[SerializeReference]</c> field (or list/array element). The drawer scans the declared
/// base type's assembly for non-abstract implementations and offers them in a dropdown.
/// </summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class SubclassSelectorAttribute : PropertyAttribute { public class SubclassSelectorAttribute : PropertyAttribute {
} }