Added the system from trail

This commit is contained in:
Sebastian Bularca
2026-04-19 12:25:49 +02:00
parent 47ee77f272
commit 89e36b4df9
57 changed files with 2160 additions and 15 deletions

8
Editor.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7d89f89053ce0384c9f7b48a5b491bca
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,341 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace Jovian.EncounterSystem.Editor {
/// <summary>
/// Designer-facing browser for every <see cref="IEncounter"/> authored across all
/// <see cref="EncounterTable"/> assets in the project. Virtualised ListView on the left,
/// property-field detail pane on the right. Search by id/name/description; filter by kind.
/// </summary>
public class EncounterBrowserWindow : EditorWindow {
private const string AllKinds = "All";
private class Record {
public EncounterTable table;
public int index;
public IEncounter encounter;
}
private readonly List<Record> allRecords = new();
private List<Record> filteredRecords = new();
private string searchText = string.Empty;
private string kindFilter = AllKinds;
private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new();
private ListView listView;
private VisualElement detailPane;
private ToolbarMenu kindDropdown;
[MenuItem("Jovian/Encounters/Encounter Browser")]
public static void Open() {
var window = GetWindow<EncounterBrowserWindow>("Encounters");
window.minSize = new Vector2(640, 360);
}
private void CreateGUI() {
BuildToolbar();
BuildSplit();
Refresh();
}
private void BuildToolbar() {
var toolbar = new Toolbar();
var search = new ToolbarSearchField();
search.style.flexGrow = 1f;
search.RegisterValueChangedCallback(evt => {
searchText = evt.newValue ?? string.Empty;
ApplyFilter();
});
toolbar.Add(search);
kindDropdown = new ToolbarMenu { text = $"Kind: {AllKinds}" };
foreach(var choice in GetKindChoices()) {
var captured = choice;
kindDropdown.menu.AppendAction(captured, _ => {
kindFilter = captured;
kindDropdown.text = $"Kind: {captured}";
ApplyFilter();
});
}
toolbar.Add(kindDropdown);
var refreshButton = new ToolbarButton(Refresh) { text = "Refresh" };
toolbar.Add(refreshButton);
rootVisualElement.Add(toolbar);
}
private void BuildSplit() {
var split = new TwoPaneSplitView(0, 280, TwoPaneSplitViewOrientation.Horizontal);
split.style.flexGrow = 1f;
rootVisualElement.Add(split);
listView = new ListView {
makeItem = MakeRow,
bindItem = BindRow,
fixedItemHeight = 22,
selectionType = SelectionType.Single
};
listView.selectionChanged += OnSelectionChanged;
listView.style.flexGrow = 1f;
split.Add(listView);
detailPane = new ScrollView(ScrollViewMode.Vertical) {
style = { paddingLeft = 8, paddingTop = 8, paddingRight = 8, flexGrow = 1f }
};
ShowEmptyDetail();
split.Add(detailPane);
}
private static VisualElement MakeRow() {
var row = new VisualElement {
style = {
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
paddingLeft = 6,
paddingRight = 6,
height = 22
}
};
var badge = new VisualElement {
name = "issue-badge",
style = {
width = 8,
height = 8,
marginRight = 6,
borderTopLeftRadius = 4,
borderTopRightRadius = 4,
borderBottomLeftRadius = 4,
borderBottomRightRadius = 4,
visibility = Visibility.Hidden
}
};
row.Add(badge);
var label = new Label {
name = "row-label",
style = {
flexGrow = 1f,
unityTextAlign = TextAnchor.MiddleLeft
}
};
row.Add(label);
return row;
}
private void BindRow(VisualElement element, int index) {
var record = filteredRecords[index];
var label = element.Q<Label>("row-label");
var badge = element.Q<VisualElement>("issue-badge");
var name = record.encounter?.EncounterDefinition?.name;
var kind = record.encounter?.Kind?.GetType().Name ?? "—";
label.text = string.IsNullOrEmpty(name)
? $"<unnamed> [{kind}]"
: $"{name} [{kind}]";
if(record.encounter != null && issuesByEncounter.TryGetValue(record.encounter, out var issues) && issues.Count > 0) {
var hasError = issues.Exists(i => i.severity == ValidationSeverity.Error);
badge.style.backgroundColor = new StyleColor(hasError ? new Color(0.85f, 0.25f, 0.25f) : new Color(0.95f, 0.75f, 0.1f));
badge.style.visibility = Visibility.Visible;
element.tooltip = BuildTooltip(issues);
}
else {
badge.style.visibility = Visibility.Hidden;
element.tooltip = string.Empty;
}
}
private static string BuildTooltip(List<ValidationIssue> issues) {
var lines = new List<string>(issues.Count);
foreach(var issue in issues) {
var prefix = issue.severity == ValidationSeverity.Error ? "ERROR" : "WARN";
lines.Add($"[{prefix}] {issue.path} — {issue.message}");
}
return string.Join("\n", lines);
}
private static IEnumerable<string> GetKindChoices() {
yield return AllKinds;
var kindTypes = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => {
try { return assembly.GetTypes(); }
catch { return Array.Empty<Type>(); }
})
.Where(type => typeof(IEncounterKind).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
.Select(type => type.Name)
.OrderBy(name => name);
foreach(var name in kindTypes) {
yield return name;
}
}
private void Refresh() {
allRecords.Clear();
var guids = AssetDatabase.FindAssets("t:" + nameof(EncounterTable));
foreach(var guid in guids) {
var path = AssetDatabase.GUIDToAssetPath(guid);
var table = AssetDatabase.LoadAssetAtPath<EncounterTable>(path);
if(table?.encounters == null) {
continue;
}
for(int i = 0; i < table.encounters.Count; i++) {
allRecords.Add(new Record {
table = table,
index = i,
encounter = table.encounters[i]
});
}
}
RebuildIssueIndex();
ApplyFilter();
}
private void RebuildIssueIndex() {
issuesByEncounter.Clear();
var issues = EncounterValidator.ValidateProject();
foreach(var issue in issues) {
if(issue.encounter == null) {
continue;
}
if(!issuesByEncounter.TryGetValue(issue.encounter, out var list)) {
list = new List<ValidationIssue>();
issuesByEncounter[issue.encounter] = list;
}
list.Add(issue);
}
}
private void ApplyFilter() {
filteredRecords = allRecords.Where(Matches).ToList();
if(listView != null) {
listView.itemsSource = filteredRecords;
listView.Rebuild();
listView.ClearSelection();
}
ShowEmptyDetail();
}
private bool Matches(Record record) {
var kindName = record.encounter?.Kind?.GetType().Name ?? string.Empty;
if(kindFilter != AllKinds && kindName != kindFilter) {
return false;
}
if(string.IsNullOrEmpty(searchText)) {
return true;
}
var needle = searchText.ToLowerInvariant();
var definition = record.encounter?.EncounterDefinition;
return Contains(definition?.id, needle)
|| Contains(definition?.name, needle)
|| Contains(definition?.description, needle);
}
private static bool Contains(string source, string needle) {
return !string.IsNullOrEmpty(source) && source.ToLowerInvariant().Contains(needle);
}
private void OnSelectionChanged(IEnumerable<object> selection) {
var record = selection.OfType<Record>().FirstOrDefault();
if(record == null) {
ShowEmptyDetail();
return;
}
detailPane.Clear();
var serializedObject = new SerializedObject(record.table);
var encountersProp = serializedObject.FindProperty(nameof(EncounterTable.encounters));
var elementProp = encountersProp.GetArrayElementAtIndex(record.index);
var header = new Label($"{record.table.name} → [{record.index}]") {
style = {
unityFontStyleAndWeight = FontStyle.Bold,
marginBottom = 6,
color = new StyleColor(new Color(0.75f, 0.75f, 0.75f))
}
};
detailPane.Add(header);
elementProp.isExpanded = true;
var field = new PropertyField(elementProp);
field.Bind(serializedObject);
detailPane.Add(field);
AddChainPreviewIfQuest(record);
}
private void AddChainPreviewIfQuest(Record record) {
if(record.encounter?.Kind is not QuestKind questKind) {
return;
}
var predecessor = FindPredecessor(record.encounter.EncounterDefinition?.internalId);
var next = questKind.nextEncounter.Resolve();
var predName = predecessor?.encounter.EncounterDefinition?.name ?? "—";
var currName = record.encounter.EncounterDefinition?.name ?? "<unnamed>";
var nextName = next?.EncounterDefinition?.name ?? "—";
var chainLabel = new Label($"Chain: {predName} ← {currName} → {nextName}") {
style = {
marginTop = 12,
paddingTop = 6,
paddingBottom = 6,
paddingLeft = 6,
paddingRight = 6,
backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.2f, 0.4f)),
unityFontStyleAndWeight = FontStyle.Bold
}
};
detailPane.Add(chainLabel);
}
private Record FindPredecessor(string targetId) {
if(string.IsNullOrEmpty(targetId)) {
return null;
}
foreach(var record in allRecords) {
if(record.encounter?.Kind is not QuestKind kind) {
continue;
}
var next = kind.nextEncounter.Resolve();
if(next?.EncounterDefinition?.internalId == targetId) {
return record;
}
}
return null;
}
private void ShowEmptyDetail() {
if(detailPane == null) {
return;
}
detailPane.Clear();
var empty = new Label("Select an encounter to edit.") {
style = { color = new StyleColor(Color.gray), marginTop = 8 }
};
detailPane.Add(empty);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0312d015c183e2f4582358d17867585c

81
Editor/EncounterDrawer.cs Normal file
View File

@@ -0,0 +1,81 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>
/// Draws each <see cref="Encounter"/> list element with its <see cref="EncounterDefinition.id"/>
/// as the foldout label (falling back to <c>name</c>, then the default element label).
/// Children are iterated manually to avoid recursing into this drawer.
/// </summary>
[CustomPropertyDrawer(typeof(Encounter))]
public class EncounterDrawer : PropertyDrawer {
private const string DefinitionBackingField = "<EncounterDefinition>k__BackingField";
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
var displayLabel = ResolveLabel(property, label);
var foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, displayLabel, true);
if(!property.isExpanded) {
return;
}
EditorGUI.indentLevel++;
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
var h = EditorGUI.GetPropertyHeight(iterator, true);
var r = new Rect(position.x, y, position.width, h);
EditorGUI.PropertyField(r, iterator, true);
y += h + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
EditorGUI.indentLevel--;
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var height = EditorGUIUtility.singleLineHeight;
if(!property.isExpanded) {
return height;
}
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
return height;
}
private static GUIContent ResolveLabel(SerializedProperty property, GUIContent fallback) {
var definition = property.FindPropertyRelative(DefinitionBackingField);
if(definition == null) {
return fallback;
}
var id = definition.FindPropertyRelative("id")?.stringValue;
if(!string.IsNullOrEmpty(id)) {
return new GUIContent(id);
}
var name = definition.FindPropertyRelative("name")?.stringValue;
if(!string.IsNullOrEmpty(name)) {
return new GUIContent(name);
}
return fallback;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ee5ffe2ea6903547ac75470249af31f

View File

@@ -0,0 +1,86 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>
/// Two-row drawer for <see cref="EncounterLink"/>. Row 1 is an asset object-field for the target
/// <see cref="EncounterTable"/>; row 2 is a dropdown of encounters inside that table labelled by
/// <c>EncounterDefinition.name</c>. Picking a different table clears the stored internalId.
/// </summary>
[CustomPropertyDrawer(typeof(EncounterLink))]
public class EncounterLinkDrawer : PropertyDrawer {
private const string NonePlaceholder = "<none>";
private const string EmptyTablePlaceholder = "<select a table first>";
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
var tableProp = property.FindPropertyRelative("table");
var idProp = property.FindPropertyRelative("internalId");
EditorGUI.BeginProperty(position, label, property);
var lineHeight = EditorGUIUtility.singleLineHeight;
var spacing = EditorGUIUtility.standardVerticalSpacing;
var tableRect = new Rect(position.x, position.y, position.width, lineHeight);
var encounterRect = new Rect(position.x, position.y + lineHeight + spacing, position.width, lineHeight);
EditorGUI.BeginChangeCheck();
EditorGUI.PropertyField(tableRect, tableProp, label);
var tableChanged = EditorGUI.EndChangeCheck();
using(new EditorGUI.IndentLevelScope()) {
var table = tableProp.objectReferenceValue as EncounterTable;
if(table == null || table.encounters == null || table.encounters.Count == 0) {
using(new EditorGUI.DisabledScope(true)) {
EditorGUI.Popup(encounterRect, "Encounter", 0, new[] { EmptyTablePlaceholder });
}
if(tableChanged) {
idProp.stringValue = string.Empty;
}
}
else {
DrawEncounterPicker(encounterRect, table, idProp, tableChanged);
}
}
EditorGUI.EndProperty();
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
return EditorGUIUtility.singleLineHeight * 2 + EditorGUIUtility.standardVerticalSpacing;
}
private static void DrawEncounterPicker(Rect rect, EncounterTable table, SerializedProperty idProp, bool tableChanged) {
var count = table.encounters.Count;
var ids = new string[count + 1];
var names = new string[count + 1];
ids[0] = string.Empty;
names[0] = NonePlaceholder;
var currentIndex = 0;
for(int i = 0; i < count; i++) {
var encounter = table.encounters[i];
var id = encounter?.EncounterDefinition?.internalId ?? string.Empty;
var name = encounter?.EncounterDefinition?.name;
ids[i + 1] = id;
names[i + 1] = string.IsNullOrEmpty(name)
? $"<unnamed> ({encounter?.GetType().Name ?? "null"})"
: name;
if(!tableChanged && id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
currentIndex = i + 1;
}
}
if(tableChanged) {
idProp.stringValue = string.Empty;
currentIndex = 0;
}
var newIndex = EditorGUI.Popup(rect, "Encounter", currentIndex, names);
if(newIndex != currentIndex) {
idProp.stringValue = ids[newIndex];
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 385fe4b7b2663e54aa3f520a809a33bc

View File

@@ -0,0 +1,266 @@
using System.Collections.Generic;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Severity of a <see cref="ValidationIssue"/>.</summary>
public enum ValidationSeverity {
Warning,
Error
}
/// <summary>
/// One thing the validator found wrong. <see cref="asset"/> is the click-through target
/// (table, dialog option set, reward), <see cref="encounter"/> identifies the specific
/// encounter the issue pertains to when applicable, and <see cref="path"/> describes where
/// inside the asset the issue lives.
/// </summary>
public class ValidationIssue {
public Object asset;
public Encounter encounter;
public string path;
public ValidationSeverity severity;
public string message;
}
/// <summary>
/// Scans every <see cref="EncounterTable"/> and <see cref="Reward"/> asset in the project and
/// returns the list of issues found. Intended to be driven from a menu item, the browser window,
/// or pre-commit tooling. Runs on demand — does not cache.
/// </summary>
public static class EncounterValidator {
/// <summary>Walk every encounter table and reward asset, returning all issues found.</summary>
public static List<ValidationIssue> ValidateProject() {
var issues = new List<ValidationIssue>();
var tables = FindAssetsOfType<EncounterTable>();
var idIndex = new Dictionary<string, (Encounter encounter, EncounterTable table)>();
foreach(var table in tables) {
ValidateTable(table, issues, idIndex);
}
foreach(var reward in FindAssetsOfType<Reward>()) {
ValidateReward(reward, issues);
}
return issues;
}
/// <summary>Return the issues for a single encounter inside a table. Useful for per-row badges.</summary>
public static List<ValidationIssue> ValidateEncounter(EncounterTable table, int index) {
var issues = new List<ValidationIssue>();
if(table?.encounters == null || index < 0 || index >= table.encounters.Count) {
return issues;
}
var encounter = table.encounters[index];
ValidateEncounterEntry(table, index, encounter, issues, duplicateCheck: null);
return issues;
}
private static void ValidateTable(EncounterTable table, List<ValidationIssue> issues, Dictionary<string, (Encounter, EncounterTable)> idIndex) {
if(table?.encounters == null) {
return;
}
for(int i = 0; i < table.encounters.Count; i++) {
ValidateEncounterEntry(table, i, table.encounters[i], issues, idIndex);
}
}
private static void ValidateEncounterEntry(EncounterTable table, int index, Encounter encounter, List<ValidationIssue> issues, Dictionary<string, (Encounter, EncounterTable)> duplicateCheck) {
var pathPrefix = $"encounters[{index}]";
if(encounter == null) {
issues.Add(new ValidationIssue {
asset = table,
path = pathPrefix,
severity = ValidationSeverity.Error,
message = "Null encounter entry."
});
return;
}
var definition = encounter.EncounterDefinition;
if(definition == null || string.IsNullOrEmpty(definition.internalId)) {
issues.Add(new ValidationIssue {
asset = table,
encounter = encounter,
path = $"{pathPrefix}.EncounterDefinition.internalId",
severity = ValidationSeverity.Error,
message = "Missing internalId (cannot be referenced by EncounterLink)."
});
}
else if(duplicateCheck != null) {
if(duplicateCheck.TryGetValue(definition.internalId, out var prior)) {
issues.Add(new ValidationIssue {
asset = table,
encounter = encounter,
path = $"{pathPrefix}.EncounterDefinition.internalId",
severity = ValidationSeverity.Error,
message = $"Duplicate internalId '{definition.internalId}' (also in table '{prior.Item2.name}')."
});
}
else {
duplicateCheck[definition.internalId] = (encounter, table);
}
}
if(encounter.Kind == null) {
issues.Add(new ValidationIssue {
asset = table,
encounter = encounter,
path = $"{pathPrefix}.Kind",
severity = ValidationSeverity.Warning,
message = "Encounter.Kind is null — pick a kind in the inspector."
});
}
if(encounter.Kind is QuestKind questKind) {
ValidateEncounterLink(table, pathPrefix + ".Kind.nextEncounter", encounter, questKind.nextEncounter, issues);
}
ValidateDialogEvents(table, index, encounter, issues);
}
private static void ValidateEncounterLink(EncounterTable owningTable, string path, Encounter encounter, EncounterLink link, List<ValidationIssue> issues) {
if(link.table == null && string.IsNullOrEmpty(link.internalId)) {
return;
}
if(link.table == null) {
issues.Add(new ValidationIssue {
asset = owningTable,
encounter = encounter,
path = path,
severity = ValidationSeverity.Error,
message = "EncounterLink has internalId set but no table assigned."
});
return;
}
if(link.Resolve() == null) {
issues.Add(new ValidationIssue {
asset = owningTable,
encounter = encounter,
path = path,
severity = ValidationSeverity.Error,
message = $"Broken EncounterLink — table '{link.table.name}' has no encounter with internalId '{link.internalId}'."
});
}
}
private static void ValidateDialogEvents(EncounterTable table, int index, Encounter encounter, List<ValidationIssue> issues) {
var optionSet = encounter.EncounterDialogOptionSet;
if(optionSet?.options == null) {
return;
}
for(int o = 0; o < optionSet.options.Count; o++) {
var option = optionSet.options[o];
if(option?.events == null) {
continue;
}
for(int e = 0; e < option.events.Count; e++) {
var evt = option.events[e];
if(evt == null) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = $"options[{o}].events[{e}]",
severity = ValidationSeverity.Warning,
message = "Null event entry (type may have been renamed/deleted)."
});
continue;
}
if(evt is GiveRewardEvent give && give.reward == null) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = $"options[{o}].events[{e}].reward",
severity = ValidationSeverity.Warning,
message = "GiveRewardEvent has no Reward asset assigned."
});
}
}
}
}
private static void ValidateReward(Reward reward, List<ValidationIssue> issues) {
if(reward == null) {
return;
}
if(reward.kind == null) {
issues.Add(new ValidationIssue {
asset = reward,
path = "kind",
severity = ValidationSeverity.Warning,
message = "Reward.kind is null — pick a reward kind in the inspector."
});
}
if(string.IsNullOrEmpty(reward.id)) {
issues.Add(new ValidationIssue {
asset = reward,
path = "id",
severity = ValidationSeverity.Warning,
message = "Reward has no id."
});
}
}
private static List<T> FindAssetsOfType<T>() where T : Object {
var result = new List<T>();
var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);
foreach(var guid in guids) {
var path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
if(asset != null) {
result.Add(asset);
}
}
return result;
}
}
/// <summary>Menu-driven runner that prints the validator report to the console with click-through asset pings.</summary>
public static class EncounterValidatorMenu {
[MenuItem("Jovian/Encounters/Validate All")]
public static void ValidateAll() {
var issues = EncounterValidator.ValidateProject();
if(issues.Count == 0) {
Debug.Log("[EncounterValidator] No issues found.");
return;
}
int errors = 0;
int warnings = 0;
foreach(var issue in issues) {
if(issue.severity == ValidationSeverity.Error) {
errors++;
}
else {
warnings++;
}
}
Debug.Log($"[EncounterValidator] {issues.Count} issue(s) found — {errors} error(s), {warnings} warning(s). Click any log row to ping the offending asset.");
foreach(var issue in issues) {
var assetName = issue.asset != null ? issue.asset.name : "<null>";
var message = $"[EncounterValidator] {assetName} · {issue.path} — {issue.message}";
if(issue.severity == ValidationSeverity.Error) {
Debug.LogError(message, issue.asset);
}
else {
Debug.LogWarning(message, issue.asset);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bab1779bc53230a4291cd3ff35774558

View File

@@ -0,0 +1,18 @@
{
"name": "Jovian.EncounterSystem.Editor",
"rootNamespace": "Jovian.EncounterSystem.Editor",
"references": [
"Jovian.EncounterSystem"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: fa750d6ecf6f41540ab10d47c136fc33
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>
/// Custom drawer for any field marked <c>[SerializeReference, SubclassSelector]</c>. Renders a
/// "pick a concrete type" dropdown populated by reflection over the declared base type, then
/// draws the chosen instance's serialized children underneath. Works for single fields, arrays,
/// and <see cref="List{T}"/> elements.
/// </summary>
/// <remarks>
/// The type cache is a <c>static readonly</c> dictionary; it is implicitly cleared on domain
/// reload because the static field is recreated with the new assembly image.
/// </remarks>
[CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
public class SubclassSelectorDrawer : PropertyDrawer {
private static readonly Dictionary<Type, Type[]> TypeCache = new();
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
if(property.propertyType != SerializedPropertyType.ManagedReference) {
EditorGUI.LabelField(position, label.text, "[SubclassSelector] requires [SerializeReference]");
return;
}
var baseType = ResolveBaseType(fieldInfo.FieldType);
var concreteTypes = GetConcreteTypes(baseType);
var currentType = GetCurrentType(property);
var currentIndex = Array.IndexOf(concreteTypes, currentType);
var names = new string[concreteTypes.Length + 1];
names[0] = "<None>";
for(int i = 0; i < concreteTypes.Length; i++) {
names[i + 1] = concreteTypes[i].Name;
}
var displayLabel = ResolveDisplayLabel(property, label);
var headerRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
var labelRect = new Rect(headerRect.x, headerRect.y, EditorGUIUtility.labelWidth, headerRect.height);
var popupRect = new Rect(headerRect.x + EditorGUIUtility.labelWidth, headerRect.y, headerRect.width - EditorGUIUtility.labelWidth, headerRect.height);
EditorGUI.LabelField(labelRect, displayLabel);
var newIndex = EditorGUI.Popup(popupRect, currentIndex + 1, names);
if(newIndex != currentIndex + 1) {
property.managedReferenceValue = newIndex == 0 ? null : Activator.CreateInstance(concreteTypes[newIndex - 1]);
property.serializedObject.ApplyModifiedProperties();
}
if(property.managedReferenceValue == null) {
return;
}
EditorGUI.indentLevel++;
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
var h = EditorGUI.GetPropertyHeight(iterator, true);
var r = new Rect(position.x, y, position.width, h);
EditorGUI.PropertyField(r, iterator, true);
y += h + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
EditorGUI.indentLevel--;
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var height = EditorGUIUtility.singleLineHeight;
if(property.propertyType != SerializedPropertyType.ManagedReference || property.managedReferenceValue == null) {
return height;
}
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
return height;
}
private static Type ResolveBaseType(Type fieldType) {
if(fieldType.IsArray) {
return fieldType.GetElementType();
}
if(fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(List<>)) {
return fieldType.GetGenericArguments()[0];
}
return fieldType;
}
private static Type[] GetConcreteTypes(Type baseType) {
if(TypeCache.TryGetValue(baseType, out var cached)) {
return cached;
}
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => {
try {
return assembly.GetTypes();
}
catch {
return Array.Empty<Type>();
}
})
.Where(type => baseType.IsAssignableFrom(type)
&& !type.IsAbstract
&& !type.IsInterface
&& !typeof(UnityEngine.Object).IsAssignableFrom(type)
&& type.GetConstructor(Type.EmptyTypes) != null)
.OrderBy(type => type.Name)
.ToArray();
TypeCache[baseType] = types;
return types;
}
private static GUIContent ResolveDisplayLabel(SerializedProperty property, GUIContent fallback) {
var value = property.managedReferenceValue;
if(value is not IEncounter) {
return fallback;
}
var definitionProp = value.GetType().GetProperty("EncounterDefinition");
var definition = definitionProp?.GetValue(value);
var nameField = definition?.GetType().GetField("name");
var name = nameField?.GetValue(definition) as string;
return string.IsNullOrEmpty(name) ? fallback : new GUIContent(name);
}
private static Type GetCurrentType(SerializedProperty property) {
var full = property.managedReferenceFullTypename;
if(string.IsNullOrEmpty(full)) {
return null;
}
var space = full.IndexOf(' ');
if(space < 0) {
return null;
}
var assembly = full.Substring(0, space);
var typeName = full.Substring(space + 1);
return Type.GetType($"{typeName}, {assembly}");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e00ec3e6bdfd024aa73689289578192

29
LICENSE
View File

@@ -1,18 +1,21 @@
MIT License MIT License
Copyright (c) 2026 uzihead Copyright (c) 2026 Sebastian Bularca
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy
associated documentation files (the "Software"), to deal in the Software without restriction, including of this software and associated documentation files (the "Software"), to deal
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell in the Software without restriction, including without limitation the rights
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
following conditions: 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 The above copyright notice and this permission notice shall be included in all
portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
USE OR OTHER DEALINGS IN THE SOFTWARE. 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.

7
LICENSE.meta Normal file
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 181516b3331a8824abeaa656991d77ee
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

167
README.md
View File

@@ -1,3 +1,166 @@
# encounter-system # 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. 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
│ ├── 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
├── 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.
### 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 |
| 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.

7
README.md.meta Normal file
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 158607149986285429176c786851ed82
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Runtime.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a3909887107f1f24aad1cd7db8a0bb28
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>One dialog line — an id/text pair in a <see cref="DialogLineLibrary"/>.</summary>
[Serializable]
public class DialogLine {
/// <summary>Stable key referenced by <see cref="DialogLineRef"/> (e.g. "common.farewell").</summary>
public string id;
/// <summary>The actual text shown to the player.</summary>
[TextArea(2, 6)]
public string text;
}
/// <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)]
public class DialogLineLibrary : ScriptableObject {
/// <summary>All lines in the library.</summary>
public List<DialogLine> lines = new();
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) {
if(string.IsNullOrEmpty(id)) {
return null;
}
EnsureCache();
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() {
cache = null;
}
private void EnsureCache() {
if(cache != null) {
return;
}
cache = new Dictionary<string, string>();
if(lines == null) {
return;
}
foreach(var line in lines) {
if(line != null && !string.IsNullOrEmpty(line.id)) {
cache[line.id] = line.text;
}
}
}
#if UNITY_EDITOR
private void OnValidate() {
InvalidateCache();
}
#endif
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 142d6e5b0f6a6cb41beddeae92b56fee

36
Runtime/DialogLineRef.cs Normal file
View File

@@ -0,0 +1,36 @@
using System;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <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]
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;
/// <summary>Fallback text used when the library reference is missing or fails to resolve.
/// 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)) {
var text = library.Resolve(id);
if(!string.IsNullOrEmpty(text)) {
return text;
}
}
return inlineText;
}
}
}

View File

@@ -0,0 +1,15 @@
namespace Jovian.EncounterSystem {
/// <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 {
/// <summary>The encounter whose dialog option fired this resolution.</summary>
public IEncounter CurrentEncounter { get; }
public EncounterContext(IEncounter currentEncounter) {
CurrentEncounter = currentEncounter;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d643c80eeffa65f4d804a795ba13bdf9

View File

@@ -0,0 +1,48 @@
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <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)]
public class EncounterDialogOptionSet : ScriptableObject {
/// <summary>Designer-facing identifier for this option set. The asset file renames to match.</summary>
public string id;
/// <summary>Ordered list of options presented to the player.</summary>
public List<EncounterDialogOption> options;
#if UNITY_EDITOR
private void OnValidate() {
if(string.IsNullOrEmpty(id)) {
return;
}
// Deferred — AssetDatabase calls are unsafe from OnValidate.
UnityEditor.EditorApplication.delayCall += RenameToMatchId;
}
private void RenameToMatchId() {
if(this == null || string.IsNullOrEmpty(id)) {
return;
}
var path = UnityEditor.AssetDatabase.GetAssetPath(this);
if(string.IsNullOrEmpty(path)) {
return;
}
if(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
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c47caaa92bb94eeca3e47dd86fd010cf
timeCreated: 1776587040

33
Runtime/EncounterLink.cs Normal file
View File

@@ -0,0 +1,33 @@
using System;
namespace Jovian.EncounterSystem {
/// <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]
public struct EncounterLink {
/// <summary>The table that owns the linked encounter.</summary>
public EncounterTable table;
/// <summary>The target encounter's stable GUID (<see cref="EncounterDefinition.internalId"/>).</summary>
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() {
if(table == null || table.encounters == null || string.IsNullOrEmpty(internalId)) {
return null;
}
foreach(var encounter in table.encounters) {
if(encounter?.EncounterDefinition?.internalId == internalId) {
return encounter;
}
}
return null;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 60455ea141b903c4390dbcdc29b46f99

View File

@@ -0,0 +1,18 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Jovian.EncounterSystem {
/// <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 TextMeshProUGUI encounterName;
public TextMeshProUGUI encounterDescription;
public Image encounterArt;
public Transform encounterOptionsContainer;
public Button submitButton;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0173ea7fbf1e45b1932694938ecd3058
timeCreated: 1776508767

View File

@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
namespace Jovian.EncounterSystem {
/// <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")]
public class EncounterRegistry : ScriptableObject {
/// <summary>Collections whose encounters should be indexed.</summary>
public EncountersCollection[] encounterCollections = Array.Empty<EncountersCollection>();
private readonly Dictionary<string, IEncounter> encounters = new();
/// <summary>The live id → encounter map.</summary>
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) {
encounters?.TryAdd(encounter?.EncounterDefinition?.internalId, encounter);
}
/// <summary>Remove an encounter from the cache by its id.</summary>
public void UnregisterEncounter(IEncounter encounter) {
encounters.Remove(encounter.EncounterDefinition.internalId);
}
/// <summary>Clear the cache. Call before a full re-population to avoid stale entries.</summary>
public void ClearEncounters() {
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() {
foreach(var collection in encounterCollections) {
foreach(var encounter in collection.encounterTables) {
foreach(var encounterInstance in encounter.encounters) {
RegisterEncounter(encounterInstance);
}
}
}
}
}
#if UNITY_EDITOR
/// <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 {
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
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0dfee180882d49c9a3d4474f389d4905
timeCreated: 1776584974

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
/// <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 {
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 {
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 {
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) {
if(events == null) {
return;
}
foreach(var evt in events) {
if(evt == null) {
continue;
}
if(handlers.TryGetValue(evt.GetType(), out var handler)) {
handler(evt, context);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d3f07fa2d5f9804d8c75e7026566757

63
Runtime/EncounterTable.cs Normal file
View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using UnityEngine;
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)]
public class EncounterTable : ScriptableObject {
/// <summary>Designer-facing table identifier (free-form).</summary>
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;
/// <summary>Pick a uniformly random encounter, or <c>null</c> if the table is empty.</summary>
public IEncounter GetRandomEncounter() {
if(encounters == null || encounters.Count == 0) {
return null;
}
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) {
if(encounters == null || encounters.Count == 0) {
return null;
}
var encountersOfType = encounters.FindAll(encounter => encounter.GetType() == type);
if(encountersOfType.Count > 0) {
return encountersOfType[UnityEngine.Random.Range(0, encountersOfType.Count)];
}
return null;
}
/// <summary>Pick a uniformly random encounter matching <paramref name="filter"/>, or <c>null</c>
/// 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) {
if(encounters == null || encounters.Count == 0 || filter == null) {
return GetRandomEncounter();
}
var pool = encounters.FindAll(filter);
if(pool.Count == 0) {
return null;
}
return pool[UnityEngine.Random.Range(0, pool.Count)];
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e480a30007b949679b8ca1e0e6088675
timeCreated: 1776507230

View File

@@ -0,0 +1,13 @@
using UnityEngine;
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)]
public class EncountersCollection : ScriptableObject {
/// <summary>The tables grouped by this collection.</summary>
public EncounterTable[] encounterTables;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 96ab08e2592347f68b8ad2e6e8d45187
timeCreated: 1776506926

92
Runtime/IEncounter.cs Normal file
View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using UnityEngine;
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 {
/// <summary>Stable identity, display name, and designer description.</summary>
EncounterDefinition EncounterDefinition { get; set; }
/// <summary>Numeric configuration shared by every encounter kind.</summary>
EncounterProperties EncounterProperties { get; set; }
/// <summary>Visual assets displayed when the encounter is presented.</summary>
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; }
/// <summary>Type-specific payload (combat, quest, social, ...). Authored via the SubclassSelector drawer.</summary>
IEncounterKind Kind { get; set; }
}
/// <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]
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; }
[field: SerializeReference, SubclassSelector]
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]
public class EncounterDefinition {
/// <summary>Stable GUID, assigned once at creation. Never edit manually.</summary>
[HideInInspector]
public string internalId = Guid.NewGuid().ToString();
/// <summary>Designer-authored short slug (e.g. "goblin_ambush").</summary>
public string id;
/// <summary>Display name shown to the player.</summary>
public string name;
/// <summary>Flavour text shown when the encounter opens.</summary>
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]
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;
/// <summary>Ordered events executed by the resolver when this option is chosen.</summary>
[SerializeReference, SubclassSelector]
public List<IEncounterEvent> events;
}
/// <summary>Visual assets for an encounter.</summary>
[Serializable]
public class EncounterVisuals {
public Sprite icon;
public Color encounterColor;
public Sprite encounterArt;
}
/// <summary>Numeric/tuning configuration shared across every encounter kind.</summary>
[Serializable]
public class EncounterProperties {
public int difficulty;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4d9c61fd5089459d8ef34cbbde0666b5
timeCreated: 1776506880

View File

@@ -0,0 +1,40 @@
using System;
namespace Jovian.EncounterSystem {
/// <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 {
}
/// <summary>Transitions the flow to another encounter identified by <see cref="nextEncounterId"/>.</summary>
[Serializable]
public class ChainToEncounterEvent : IEncounterEvent {
public string nextEncounterId;
}
/// <summary>Starts a combat encounter by id. Intended to be handled by the combat play mode.</summary>
[Serializable]
public class StartCombatEvent : IEncounterEvent {
public string combatEncounterId;
}
/// <summary>Writes a line to whatever log sink the resolver's handler is configured with. Useful for debugging.</summary>
[Serializable]
public class LogEvent : IEncounterEvent {
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]
public class GiveRewardEvent : IEncounterEvent {
public Reward reward;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e42b3f5f74428d944a68a320a821f0c9

65
Runtime/IEncounterKind.cs Normal file
View File

@@ -0,0 +1,65 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>
/// Marker interface for the polymorphic payload of an <see cref="IEncounter"/>.
/// 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 {
}
/// <summary>Combat encounter: triggers combat play mode with the specified enemy group.</summary>
[Serializable]
public class CombatKind : IEncounterKind {
public string enemyGroupId;
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]
public class QuestKind : IEncounterKind {
public EncounterLink nextEncounter;
public string questTitle;
}
/// <summary>Dialogue-driven encounter with an NPC and optional faction reputation impact.</summary>
[Serializable]
public class SocialKind : IEncounterKind {
public string npcId;
public string factionId;
public int reputationDelta;
}
/// <summary>Puzzle encounter gated by a skill check against <see cref="difficultyClass"/>.</summary>
[Serializable]
public class PuzzleKind : IEncounterKind {
public string puzzleId;
public int difficultyClass;
}
/// <summary>Exploration/discovery encounter gated by a perception check (<see cref="perceptionDC"/>).</summary>
[Serializable]
public class ExplorationKind : IEncounterKind {
public int perceptionDC;
}
/// <summary>Tutorial encounter driving a tutorial subsystem by id.</summary>
[Serializable]
public class TutorialKind : IEncounterKind {
public string tutorialId;
}
/// <summary>Environmental hazard that applies damage or a status without player choice.</summary>
[Serializable]
public class HazardKind : IEncounterKind {
public int damageAmount;
}
/// <summary>Catch-all with no kind-specific data — useful while prototyping.</summary>
[Serializable]
public class OtherKind : IEncounterKind {
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28cc80b36b63e6e44b7f1cfb6c57bf62

46
Runtime/IRewardKind.cs Normal file
View File

@@ -0,0 +1,46 @@
using System;
namespace Jovian.EncounterSystem {
/// <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 {
}
/// <summary>Grants an amount of a named currency (gold, silver, faction-specific scrip, ...).</summary>
[Serializable]
public class CurrencyRewardKind : IRewardKind {
public string currencyId;
public int amount;
}
/// <summary>Grants one or more copies of an item identified by id.</summary>
[Serializable]
public class ItemRewardKind : IRewardKind {
public string itemId;
public int quantity;
}
/// <summary>Grants experience points to the party or a specific recipient.</summary>
[Serializable]
public class ExperienceRewardKind : IRewardKind {
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]
public class UnlockableRewardKind : IRewardKind {
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]
public class OtherRewardKind : IRewardKind {
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2793c02adec79d4088031ab399c16e1

View File

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

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0452817d1bdb1084da85c56a64179c01
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

80
Runtime/QuestLog.cs Normal file
View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Jovian.EncounterSystem {
/// <summary>Tag for a quest log entry.</summary>
public enum QuestLogEventType {
Started,
Advanced,
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]
public class QuestLogEntry {
public QuestLogEventType type;
public string encounterInternalId;
public string encounterName;
public string fromEncounterName;
}
/// <summary>
/// Chronological, serializable record of quest events. Subscribes to <see cref="QuestProgress"/>
/// events at construction time — build it before any encounter fires so nothing is missed.
/// </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 {
private readonly List<QuestLogEntry> entries = new();
/// <summary>The log in chronological order.</summary>
public IReadOnlyList<QuestLogEntry> Entries => entries;
/// <summary>Subscribe to <paramref name="progress"/>'s quest events; every fire appends an entry.</summary>
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);
}
/// <summary>Return a copy of the current entries suitable for serialization.</summary>
public List<QuestLogEntry> CreateSnapshot() {
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) {
entries.Clear();
if(saved == null) {
return;
}
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() {
return entries
.Select(entry => entry.encounterInternalId)
.Where(id => !string.IsNullOrEmpty(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
});
}
}
}

2
Runtime/QuestLog.cs.meta Normal file
View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 74f83e6449bec1847bcb25cb5398a682

123
Runtime/QuestProgress.cs Normal file
View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
/// <summary>
/// Tracks which <see cref="QuestKind"/> encounters the party has resolved and gates any quest
/// encounter whose predecessor hasn't fired yet. Consumers use <see cref="IsGated"/> to exclude
/// blocked encounters from rolls, and <see cref="OnEncounterTriggered"/> to mark progress.
/// </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 {
private readonly HashSet<string> resolvedIds = 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;
/// <summary>Fires when a chained quest encounter is resolved. Args: (previous, current).</summary>
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;
/// <summary>Snapshot of every quest encounter id the party has resolved. Save this in the save file.</summary>
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) {
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) {
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);
}
/// <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) {
if(encounter?.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);
}
}
/// <summary>Restore resolved quest ids from save data. Call after construction, before the
/// first <see cref="OnEncounterTriggered"/>.</summary>
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;
}
foreach(var table in collection.encounterTables) {
if(table?.encounters == null) {
continue;
}
foreach(var encounter in table.encounters) {
if(encounter?.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;
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2eacdbd0e462b9b46b7f3cce64d26765

22
Runtime/Reward.cs Normal file
View File

@@ -0,0 +1,22 @@
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <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)]
public class Reward : ScriptableObject {
/// <summary>Designer-facing short slug (e.g. "gold_10").</summary>
public string id;
/// <summary>Display name shown to the player when the reward is granted.</summary>
public string displayName;
/// <summary>Type-specific payload (currency, item, experience, ...). Authored via the SubclassSelector drawer.</summary>
[SerializeReference, SubclassSelector]
public IRewardKind kind;
}
}

2
Runtime/Reward.cs.meta Normal file
View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ce47d4bfb319877429589295ac214255

View File

@@ -0,0 +1,13 @@
using System;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <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)]
public class SubclassSelectorAttribute : PropertyAttribute {
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ef3578112ccfa3447b74712009145c75

19
package.json Normal file
View File

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

7
package.json.meta Normal file
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 75b61f91daea15646839c76fcba9a1d7
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: