Added the system from trail
This commit is contained in:
8
Editor.meta
Normal file
8
Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d89f89053ce0384c9f7b48a5b491bca
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
341
Editor/EncounterBrowserWindow.cs
Normal file
341
Editor/EncounterBrowserWindow.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Designer-facing browser for every <see cref="IEncounter"/> authored across all
|
||||
/// <see cref="EncounterTable"/> assets in the project. Virtualised ListView on the left,
|
||||
/// property-field detail pane on the right. Search by id/name/description; filter by kind.
|
||||
/// </summary>
|
||||
public class EncounterBrowserWindow : EditorWindow {
|
||||
private const string AllKinds = "All";
|
||||
|
||||
private class Record {
|
||||
public EncounterTable table;
|
||||
public int index;
|
||||
public IEncounter encounter;
|
||||
}
|
||||
|
||||
private readonly List<Record> allRecords = new();
|
||||
private List<Record> filteredRecords = new();
|
||||
private string searchText = string.Empty;
|
||||
private string kindFilter = AllKinds;
|
||||
|
||||
private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new();
|
||||
|
||||
private ListView listView;
|
||||
private VisualElement detailPane;
|
||||
private ToolbarMenu kindDropdown;
|
||||
|
||||
[MenuItem("Jovian/Encounters/Encounter Browser")]
|
||||
public static void Open() {
|
||||
var window = GetWindow<EncounterBrowserWindow>("Encounters");
|
||||
window.minSize = new Vector2(640, 360);
|
||||
}
|
||||
|
||||
private void CreateGUI() {
|
||||
BuildToolbar();
|
||||
BuildSplit();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void BuildToolbar() {
|
||||
var toolbar = new Toolbar();
|
||||
|
||||
var search = new ToolbarSearchField();
|
||||
search.style.flexGrow = 1f;
|
||||
search.RegisterValueChangedCallback(evt => {
|
||||
searchText = evt.newValue ?? string.Empty;
|
||||
ApplyFilter();
|
||||
});
|
||||
toolbar.Add(search);
|
||||
|
||||
kindDropdown = new ToolbarMenu { text = $"Kind: {AllKinds}" };
|
||||
foreach(var choice in GetKindChoices()) {
|
||||
var captured = choice;
|
||||
kindDropdown.menu.AppendAction(captured, _ => {
|
||||
kindFilter = captured;
|
||||
kindDropdown.text = $"Kind: {captured}";
|
||||
ApplyFilter();
|
||||
});
|
||||
}
|
||||
toolbar.Add(kindDropdown);
|
||||
|
||||
var refreshButton = new ToolbarButton(Refresh) { text = "Refresh" };
|
||||
toolbar.Add(refreshButton);
|
||||
|
||||
rootVisualElement.Add(toolbar);
|
||||
}
|
||||
|
||||
private void BuildSplit() {
|
||||
var split = new TwoPaneSplitView(0, 280, TwoPaneSplitViewOrientation.Horizontal);
|
||||
split.style.flexGrow = 1f;
|
||||
rootVisualElement.Add(split);
|
||||
|
||||
listView = new ListView {
|
||||
makeItem = MakeRow,
|
||||
bindItem = BindRow,
|
||||
fixedItemHeight = 22,
|
||||
selectionType = SelectionType.Single
|
||||
};
|
||||
listView.selectionChanged += OnSelectionChanged;
|
||||
listView.style.flexGrow = 1f;
|
||||
split.Add(listView);
|
||||
|
||||
detailPane = new ScrollView(ScrollViewMode.Vertical) {
|
||||
style = { paddingLeft = 8, paddingTop = 8, paddingRight = 8, flexGrow = 1f }
|
||||
};
|
||||
ShowEmptyDetail();
|
||||
split.Add(detailPane);
|
||||
}
|
||||
|
||||
private static VisualElement MakeRow() {
|
||||
var row = new VisualElement {
|
||||
style = {
|
||||
flexDirection = FlexDirection.Row,
|
||||
alignItems = Align.Center,
|
||||
paddingLeft = 6,
|
||||
paddingRight = 6,
|
||||
height = 22
|
||||
}
|
||||
};
|
||||
|
||||
var badge = new VisualElement {
|
||||
name = "issue-badge",
|
||||
style = {
|
||||
width = 8,
|
||||
height = 8,
|
||||
marginRight = 6,
|
||||
borderTopLeftRadius = 4,
|
||||
borderTopRightRadius = 4,
|
||||
borderBottomLeftRadius = 4,
|
||||
borderBottomRightRadius = 4,
|
||||
visibility = Visibility.Hidden
|
||||
}
|
||||
};
|
||||
row.Add(badge);
|
||||
|
||||
var label = new Label {
|
||||
name = "row-label",
|
||||
style = {
|
||||
flexGrow = 1f,
|
||||
unityTextAlign = TextAnchor.MiddleLeft
|
||||
}
|
||||
};
|
||||
row.Add(label);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private void BindRow(VisualElement element, int index) {
|
||||
var record = filteredRecords[index];
|
||||
var label = element.Q<Label>("row-label");
|
||||
var badge = element.Q<VisualElement>("issue-badge");
|
||||
|
||||
var name = record.encounter?.EncounterDefinition?.name;
|
||||
var kind = record.encounter?.Kind?.GetType().Name ?? "—";
|
||||
label.text = string.IsNullOrEmpty(name)
|
||||
? $"<unnamed> [{kind}]"
|
||||
: $"{name} [{kind}]";
|
||||
|
||||
if(record.encounter != null && issuesByEncounter.TryGetValue(record.encounter, out var issues) && issues.Count > 0) {
|
||||
var hasError = issues.Exists(i => i.severity == ValidationSeverity.Error);
|
||||
badge.style.backgroundColor = new StyleColor(hasError ? new Color(0.85f, 0.25f, 0.25f) : new Color(0.95f, 0.75f, 0.1f));
|
||||
badge.style.visibility = Visibility.Visible;
|
||||
element.tooltip = BuildTooltip(issues);
|
||||
}
|
||||
else {
|
||||
badge.style.visibility = Visibility.Hidden;
|
||||
element.tooltip = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildTooltip(List<ValidationIssue> issues) {
|
||||
var lines = new List<string>(issues.Count);
|
||||
foreach(var issue in issues) {
|
||||
var prefix = issue.severity == ValidationSeverity.Error ? "ERROR" : "WARN";
|
||||
lines.Add($"[{prefix}] {issue.path} — {issue.message}");
|
||||
}
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetKindChoices() {
|
||||
yield return AllKinds;
|
||||
|
||||
var kindTypes = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(assembly => {
|
||||
try { return assembly.GetTypes(); }
|
||||
catch { return Array.Empty<Type>(); }
|
||||
})
|
||||
.Where(type => typeof(IEncounterKind).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
|
||||
.Select(type => type.Name)
|
||||
.OrderBy(name => name);
|
||||
|
||||
foreach(var name in kindTypes) {
|
||||
yield return name;
|
||||
}
|
||||
}
|
||||
|
||||
private void Refresh() {
|
||||
allRecords.Clear();
|
||||
|
||||
var guids = AssetDatabase.FindAssets("t:" + nameof(EncounterTable));
|
||||
foreach(var guid in guids) {
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var table = AssetDatabase.LoadAssetAtPath<EncounterTable>(path);
|
||||
if(table?.encounters == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for(int i = 0; i < table.encounters.Count; i++) {
|
||||
allRecords.Add(new Record {
|
||||
table = table,
|
||||
index = i,
|
||||
encounter = table.encounters[i]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
RebuildIssueIndex();
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void RebuildIssueIndex() {
|
||||
issuesByEncounter.Clear();
|
||||
var issues = EncounterValidator.ValidateProject();
|
||||
foreach(var issue in issues) {
|
||||
if(issue.encounter == null) {
|
||||
continue;
|
||||
}
|
||||
if(!issuesByEncounter.TryGetValue(issue.encounter, out var list)) {
|
||||
list = new List<ValidationIssue>();
|
||||
issuesByEncounter[issue.encounter] = list;
|
||||
}
|
||||
list.Add(issue);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFilter() {
|
||||
filteredRecords = allRecords.Where(Matches).ToList();
|
||||
if(listView != null) {
|
||||
listView.itemsSource = filteredRecords;
|
||||
listView.Rebuild();
|
||||
listView.ClearSelection();
|
||||
}
|
||||
ShowEmptyDetail();
|
||||
}
|
||||
|
||||
private bool Matches(Record record) {
|
||||
var kindName = record.encounter?.Kind?.GetType().Name ?? string.Empty;
|
||||
if(kindFilter != AllKinds && kindName != kindFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(string.IsNullOrEmpty(searchText)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var needle = searchText.ToLowerInvariant();
|
||||
var definition = record.encounter?.EncounterDefinition;
|
||||
return Contains(definition?.id, needle)
|
||||
|| Contains(definition?.name, needle)
|
||||
|| Contains(definition?.description, needle);
|
||||
}
|
||||
|
||||
private static bool Contains(string source, string needle) {
|
||||
return !string.IsNullOrEmpty(source) && source.ToLowerInvariant().Contains(needle);
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(IEnumerable<object> selection) {
|
||||
var record = selection.OfType<Record>().FirstOrDefault();
|
||||
if(record == null) {
|
||||
ShowEmptyDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
detailPane.Clear();
|
||||
|
||||
var serializedObject = new SerializedObject(record.table);
|
||||
var encountersProp = serializedObject.FindProperty(nameof(EncounterTable.encounters));
|
||||
var elementProp = encountersProp.GetArrayElementAtIndex(record.index);
|
||||
|
||||
var header = new Label($"{record.table.name} → [{record.index}]") {
|
||||
style = {
|
||||
unityFontStyleAndWeight = FontStyle.Bold,
|
||||
marginBottom = 6,
|
||||
color = new StyleColor(new Color(0.75f, 0.75f, 0.75f))
|
||||
}
|
||||
};
|
||||
detailPane.Add(header);
|
||||
|
||||
elementProp.isExpanded = true;
|
||||
var field = new PropertyField(elementProp);
|
||||
field.Bind(serializedObject);
|
||||
detailPane.Add(field);
|
||||
|
||||
AddChainPreviewIfQuest(record);
|
||||
}
|
||||
|
||||
private void AddChainPreviewIfQuest(Record record) {
|
||||
if(record.encounter?.Kind is not QuestKind questKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
var predecessor = FindPredecessor(record.encounter.EncounterDefinition?.internalId);
|
||||
var next = questKind.nextEncounter.Resolve();
|
||||
|
||||
var predName = predecessor?.encounter.EncounterDefinition?.name ?? "—";
|
||||
var currName = record.encounter.EncounterDefinition?.name ?? "<unnamed>";
|
||||
var nextName = next?.EncounterDefinition?.name ?? "—";
|
||||
|
||||
var chainLabel = new Label($"Chain: {predName} ← {currName} → {nextName}") {
|
||||
style = {
|
||||
marginTop = 12,
|
||||
paddingTop = 6,
|
||||
paddingBottom = 6,
|
||||
paddingLeft = 6,
|
||||
paddingRight = 6,
|
||||
backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.2f, 0.4f)),
|
||||
unityFontStyleAndWeight = FontStyle.Bold
|
||||
}
|
||||
};
|
||||
detailPane.Add(chainLabel);
|
||||
}
|
||||
|
||||
private Record FindPredecessor(string targetId) {
|
||||
if(string.IsNullOrEmpty(targetId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach(var record in allRecords) {
|
||||
if(record.encounter?.Kind is not QuestKind kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = kind.nextEncounter.Resolve();
|
||||
if(next?.EncounterDefinition?.internalId == targetId) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ShowEmptyDetail() {
|
||||
if(detailPane == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
detailPane.Clear();
|
||||
var empty = new Label("Select an encounter to edit.") {
|
||||
style = { color = new StyleColor(Color.gray), marginTop = 8 }
|
||||
};
|
||||
detailPane.Add(empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterBrowserWindow.cs.meta
Normal file
2
Editor/EncounterBrowserWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0312d015c183e2f4582358d17867585c
|
||||
81
Editor/EncounterDrawer.cs
Normal file
81
Editor/EncounterDrawer.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Draws each <see cref="Encounter"/> list element with its <see cref="EncounterDefinition.id"/>
|
||||
/// as the foldout label (falling back to <c>name</c>, then the default element label).
|
||||
/// Children are iterated manually to avoid recursing into this drawer.
|
||||
/// </summary>
|
||||
[CustomPropertyDrawer(typeof(Encounter))]
|
||||
public class EncounterDrawer : PropertyDrawer {
|
||||
private const string DefinitionBackingField = "<EncounterDefinition>k__BackingField";
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
var displayLabel = ResolveLabel(property, label);
|
||||
|
||||
var foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
|
||||
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, displayLabel, true);
|
||||
|
||||
if(!property.isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
var h = EditorGUI.GetPropertyHeight(iterator, true);
|
||||
var r = new Rect(position.x, y, position.width, h);
|
||||
EditorGUI.PropertyField(r, iterator, true);
|
||||
y += h + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
var height = EditorGUIUtility.singleLineHeight;
|
||||
if(!property.isExpanded) {
|
||||
return height;
|
||||
}
|
||||
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private static GUIContent ResolveLabel(SerializedProperty property, GUIContent fallback) {
|
||||
var definition = property.FindPropertyRelative(DefinitionBackingField);
|
||||
if(definition == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var id = definition.FindPropertyRelative("id")?.stringValue;
|
||||
if(!string.IsNullOrEmpty(id)) {
|
||||
return new GUIContent(id);
|
||||
}
|
||||
|
||||
var name = definition.FindPropertyRelative("name")?.stringValue;
|
||||
if(!string.IsNullOrEmpty(name)) {
|
||||
return new GUIContent(name);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterDrawer.cs.meta
Normal file
2
Editor/EncounterDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ee5ffe2ea6903547ac75470249af31f
|
||||
86
Editor/EncounterLinkDrawer.cs
Normal file
86
Editor/EncounterLinkDrawer.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Two-row drawer for <see cref="EncounterLink"/>. Row 1 is an asset object-field for the target
|
||||
/// <see cref="EncounterTable"/>; row 2 is a dropdown of encounters inside that table labelled by
|
||||
/// <c>EncounterDefinition.name</c>. Picking a different table clears the stored internalId.
|
||||
/// </summary>
|
||||
[CustomPropertyDrawer(typeof(EncounterLink))]
|
||||
public class EncounterLinkDrawer : PropertyDrawer {
|
||||
private const string NonePlaceholder = "<none>";
|
||||
private const string EmptyTablePlaceholder = "<select a table first>";
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
var tableProp = property.FindPropertyRelative("table");
|
||||
var idProp = property.FindPropertyRelative("internalId");
|
||||
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
|
||||
var lineHeight = EditorGUIUtility.singleLineHeight;
|
||||
var spacing = EditorGUIUtility.standardVerticalSpacing;
|
||||
|
||||
var tableRect = new Rect(position.x, position.y, position.width, lineHeight);
|
||||
var encounterRect = new Rect(position.x, position.y + lineHeight + spacing, position.width, lineHeight);
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
EditorGUI.PropertyField(tableRect, tableProp, label);
|
||||
var tableChanged = EditorGUI.EndChangeCheck();
|
||||
|
||||
using(new EditorGUI.IndentLevelScope()) {
|
||||
var table = tableProp.objectReferenceValue as EncounterTable;
|
||||
if(table == null || table.encounters == null || table.encounters.Count == 0) {
|
||||
using(new EditorGUI.DisabledScope(true)) {
|
||||
EditorGUI.Popup(encounterRect, "Encounter", 0, new[] { EmptyTablePlaceholder });
|
||||
}
|
||||
if(tableChanged) {
|
||||
idProp.stringValue = string.Empty;
|
||||
}
|
||||
}
|
||||
else {
|
||||
DrawEncounterPicker(encounterRect, table, idProp, tableChanged);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.EndProperty();
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
return EditorGUIUtility.singleLineHeight * 2 + EditorGUIUtility.standardVerticalSpacing;
|
||||
}
|
||||
|
||||
private static void DrawEncounterPicker(Rect rect, EncounterTable table, SerializedProperty idProp, bool tableChanged) {
|
||||
var count = table.encounters.Count;
|
||||
var ids = new string[count + 1];
|
||||
var names = new string[count + 1];
|
||||
ids[0] = string.Empty;
|
||||
names[0] = NonePlaceholder;
|
||||
|
||||
var currentIndex = 0;
|
||||
for(int i = 0; i < count; i++) {
|
||||
var encounter = table.encounters[i];
|
||||
var id = encounter?.EncounterDefinition?.internalId ?? string.Empty;
|
||||
var name = encounter?.EncounterDefinition?.name;
|
||||
ids[i + 1] = id;
|
||||
names[i + 1] = string.IsNullOrEmpty(name)
|
||||
? $"<unnamed> ({encounter?.GetType().Name ?? "null"})"
|
||||
: name;
|
||||
if(!tableChanged && id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
|
||||
currentIndex = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if(tableChanged) {
|
||||
idProp.stringValue = string.Empty;
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
var newIndex = EditorGUI.Popup(rect, "Encounter", currentIndex, names);
|
||||
if(newIndex != currentIndex) {
|
||||
idProp.stringValue = ids[newIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterLinkDrawer.cs.meta
Normal file
2
Editor/EncounterLinkDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 385fe4b7b2663e54aa3f520a809a33bc
|
||||
266
Editor/EncounterValidator.cs
Normal file
266
Editor/EncounterValidator.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Generic;
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>Severity of a <see cref="ValidationIssue"/>.</summary>
|
||||
public enum ValidationSeverity {
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One thing the validator found wrong. <see cref="asset"/> is the click-through target
|
||||
/// (table, dialog option set, reward), <see cref="encounter"/> identifies the specific
|
||||
/// encounter the issue pertains to when applicable, and <see cref="path"/> describes where
|
||||
/// inside the asset the issue lives.
|
||||
/// </summary>
|
||||
public class ValidationIssue {
|
||||
public Object asset;
|
||||
public Encounter encounter;
|
||||
public string path;
|
||||
public ValidationSeverity severity;
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every <see cref="EncounterTable"/> and <see cref="Reward"/> asset in the project and
|
||||
/// returns the list of issues found. Intended to be driven from a menu item, the browser window,
|
||||
/// or pre-commit tooling. Runs on demand — does not cache.
|
||||
/// </summary>
|
||||
public static class EncounterValidator {
|
||||
/// <summary>Walk every encounter table and reward asset, returning all issues found.</summary>
|
||||
public static List<ValidationIssue> ValidateProject() {
|
||||
var issues = new List<ValidationIssue>();
|
||||
|
||||
var tables = FindAssetsOfType<EncounterTable>();
|
||||
var idIndex = new Dictionary<string, (Encounter encounter, EncounterTable table)>();
|
||||
|
||||
foreach(var table in tables) {
|
||||
ValidateTable(table, issues, idIndex);
|
||||
}
|
||||
|
||||
foreach(var reward in FindAssetsOfType<Reward>()) {
|
||||
ValidateReward(reward, issues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// <summary>Return the issues for a single encounter inside a table. Useful for per-row badges.</summary>
|
||||
public static List<ValidationIssue> ValidateEncounter(EncounterTable table, int index) {
|
||||
var issues = new List<ValidationIssue>();
|
||||
if(table?.encounters == null || index < 0 || index >= table.encounters.Count) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
var encounter = table.encounters[index];
|
||||
ValidateEncounterEntry(table, index, encounter, issues, duplicateCheck: null);
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static void ValidateTable(EncounterTable table, List<ValidationIssue> issues, Dictionary<string, (Encounter, EncounterTable)> idIndex) {
|
||||
if(table?.encounters == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for(int i = 0; i < table.encounters.Count; i++) {
|
||||
ValidateEncounterEntry(table, i, table.encounters[i], issues, idIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEncounterEntry(EncounterTable table, int index, Encounter encounter, List<ValidationIssue> issues, Dictionary<string, (Encounter, EncounterTable)> duplicateCheck) {
|
||||
var pathPrefix = $"encounters[{index}]";
|
||||
|
||||
if(encounter == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
path = pathPrefix,
|
||||
severity = ValidationSeverity.Error,
|
||||
message = "Null encounter entry."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var definition = encounter.EncounterDefinition;
|
||||
if(definition == null || string.IsNullOrEmpty(definition.internalId)) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
encounter = encounter,
|
||||
path = $"{pathPrefix}.EncounterDefinition.internalId",
|
||||
severity = ValidationSeverity.Error,
|
||||
message = "Missing internalId (cannot be referenced by EncounterLink)."
|
||||
});
|
||||
}
|
||||
else if(duplicateCheck != null) {
|
||||
if(duplicateCheck.TryGetValue(definition.internalId, out var prior)) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
encounter = encounter,
|
||||
path = $"{pathPrefix}.EncounterDefinition.internalId",
|
||||
severity = ValidationSeverity.Error,
|
||||
message = $"Duplicate internalId '{definition.internalId}' (also in table '{prior.Item2.name}')."
|
||||
});
|
||||
}
|
||||
else {
|
||||
duplicateCheck[definition.internalId] = (encounter, table);
|
||||
}
|
||||
}
|
||||
|
||||
if(encounter.Kind == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = table,
|
||||
encounter = encounter,
|
||||
path = $"{pathPrefix}.Kind",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Encounter.Kind is null — pick a kind in the inspector."
|
||||
});
|
||||
}
|
||||
|
||||
if(encounter.Kind is QuestKind questKind) {
|
||||
ValidateEncounterLink(table, pathPrefix + ".Kind.nextEncounter", encounter, questKind.nextEncounter, issues);
|
||||
}
|
||||
|
||||
ValidateDialogEvents(table, index, encounter, issues);
|
||||
}
|
||||
|
||||
private static void ValidateEncounterLink(EncounterTable owningTable, string path, Encounter encounter, EncounterLink link, List<ValidationIssue> issues) {
|
||||
if(link.table == null && string.IsNullOrEmpty(link.internalId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(link.table == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = owningTable,
|
||||
encounter = encounter,
|
||||
path = path,
|
||||
severity = ValidationSeverity.Error,
|
||||
message = "EncounterLink has internalId set but no table assigned."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if(link.Resolve() == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = owningTable,
|
||||
encounter = encounter,
|
||||
path = path,
|
||||
severity = ValidationSeverity.Error,
|
||||
message = $"Broken EncounterLink — table '{link.table.name}' has no encounter with internalId '{link.internalId}'."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDialogEvents(EncounterTable table, int index, Encounter encounter, List<ValidationIssue> issues) {
|
||||
var optionSet = encounter.EncounterDialogOptionSet;
|
||||
if(optionSet?.options == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for(int o = 0; o < optionSet.options.Count; o++) {
|
||||
var option = optionSet.options[o];
|
||||
if(option?.events == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for(int e = 0; e < option.events.Count; e++) {
|
||||
var evt = option.events[e];
|
||||
if(evt == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = optionSet,
|
||||
encounter = encounter,
|
||||
path = $"options[{o}].events[{e}]",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Null event entry (type may have been renamed/deleted)."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if(evt is GiveRewardEvent give && give.reward == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = optionSet,
|
||||
encounter = encounter,
|
||||
path = $"options[{o}].events[{e}].reward",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "GiveRewardEvent has no Reward asset assigned."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateReward(Reward reward, List<ValidationIssue> issues) {
|
||||
if(reward == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(reward.kind == null) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = reward,
|
||||
path = "kind",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Reward.kind is null — pick a reward kind in the inspector."
|
||||
});
|
||||
}
|
||||
|
||||
if(string.IsNullOrEmpty(reward.id)) {
|
||||
issues.Add(new ValidationIssue {
|
||||
asset = reward,
|
||||
path = "id",
|
||||
severity = ValidationSeverity.Warning,
|
||||
message = "Reward has no id."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static List<T> FindAssetsOfType<T>() where T : Object {
|
||||
var result = new List<T>();
|
||||
var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);
|
||||
foreach(var guid in guids) {
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
|
||||
if(asset != null) {
|
||||
result.Add(asset);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Menu-driven runner that prints the validator report to the console with click-through asset pings.</summary>
|
||||
public static class EncounterValidatorMenu {
|
||||
[MenuItem("Jovian/Encounters/Validate All")]
|
||||
public static void ValidateAll() {
|
||||
var issues = EncounterValidator.ValidateProject();
|
||||
if(issues.Count == 0) {
|
||||
Debug.Log("[EncounterValidator] No issues found.");
|
||||
return;
|
||||
}
|
||||
|
||||
int errors = 0;
|
||||
int warnings = 0;
|
||||
foreach(var issue in issues) {
|
||||
if(issue.severity == ValidationSeverity.Error) {
|
||||
errors++;
|
||||
}
|
||||
else {
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[EncounterValidator] {issues.Count} issue(s) found — {errors} error(s), {warnings} warning(s). Click any log row to ping the offending asset.");
|
||||
|
||||
foreach(var issue in issues) {
|
||||
var assetName = issue.asset != null ? issue.asset.name : "<null>";
|
||||
var message = $"[EncounterValidator] {assetName} · {issue.path} — {issue.message}";
|
||||
if(issue.severity == ValidationSeverity.Error) {
|
||||
Debug.LogError(message, issue.asset);
|
||||
}
|
||||
else {
|
||||
Debug.LogWarning(message, issue.asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/EncounterValidator.cs.meta
Normal file
2
Editor/EncounterValidator.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bab1779bc53230a4291cd3ff35774558
|
||||
18
Editor/Jovian.EncounterSystem.Editor.asmdef
Normal file
18
Editor/Jovian.EncounterSystem.Editor.asmdef
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Jovian.EncounterSystem.Editor",
|
||||
"rootNamespace": "Jovian.EncounterSystem.Editor",
|
||||
"references": [
|
||||
"Jovian.EncounterSystem"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
7
Editor/Jovian.EncounterSystem.Editor.asmdef.meta
Normal file
7
Editor/Jovian.EncounterSystem.Editor.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa750d6ecf6f41540ab10d47c136fc33
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
157
Editor/SubclassSelectorDrawer.cs
Normal file
157
Editor/SubclassSelectorDrawer.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jovian.EncounterSystem;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.EncounterSystem.Editor {
|
||||
/// <summary>
|
||||
/// Custom drawer for any field marked <c>[SerializeReference, SubclassSelector]</c>. Renders a
|
||||
/// "pick a concrete type" dropdown populated by reflection over the declared base type, then
|
||||
/// draws the chosen instance's serialized children underneath. Works for single fields, arrays,
|
||||
/// and <see cref="List{T}"/> elements.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The type cache is a <c>static readonly</c> dictionary; it is implicitly cleared on domain
|
||||
/// reload because the static field is recreated with the new assembly image.
|
||||
/// </remarks>
|
||||
[CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
|
||||
public class SubclassSelectorDrawer : PropertyDrawer {
|
||||
private static readonly Dictionary<Type, Type[]> TypeCache = new();
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
|
||||
if(property.propertyType != SerializedPropertyType.ManagedReference) {
|
||||
EditorGUI.LabelField(position, label.text, "[SubclassSelector] requires [SerializeReference]");
|
||||
return;
|
||||
}
|
||||
|
||||
var baseType = ResolveBaseType(fieldInfo.FieldType);
|
||||
var concreteTypes = GetConcreteTypes(baseType);
|
||||
var currentType = GetCurrentType(property);
|
||||
var currentIndex = Array.IndexOf(concreteTypes, currentType);
|
||||
|
||||
var names = new string[concreteTypes.Length + 1];
|
||||
names[0] = "<None>";
|
||||
for(int i = 0; i < concreteTypes.Length; i++) {
|
||||
names[i + 1] = concreteTypes[i].Name;
|
||||
}
|
||||
|
||||
var displayLabel = ResolveDisplayLabel(property, label);
|
||||
var headerRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
|
||||
var labelRect = new Rect(headerRect.x, headerRect.y, EditorGUIUtility.labelWidth, headerRect.height);
|
||||
var popupRect = new Rect(headerRect.x + EditorGUIUtility.labelWidth, headerRect.y, headerRect.width - EditorGUIUtility.labelWidth, headerRect.height);
|
||||
|
||||
EditorGUI.LabelField(labelRect, displayLabel);
|
||||
var newIndex = EditorGUI.Popup(popupRect, currentIndex + 1, names);
|
||||
if(newIndex != currentIndex + 1) {
|
||||
property.managedReferenceValue = newIndex == 0 ? null : Activator.CreateInstance(concreteTypes[newIndex - 1]);
|
||||
property.serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
if(property.managedReferenceValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
var h = EditorGUI.GetPropertyHeight(iterator, true);
|
||||
var r = new Rect(position.x, y, position.width, h);
|
||||
EditorGUI.PropertyField(r, iterator, true);
|
||||
y += h + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
|
||||
var height = EditorGUIUtility.singleLineHeight;
|
||||
if(property.propertyType != SerializedPropertyType.ManagedReference || property.managedReferenceValue == null) {
|
||||
return height;
|
||||
}
|
||||
|
||||
var iterator = property.Copy();
|
||||
var end = iterator.GetEndProperty();
|
||||
if(iterator.NextVisible(true)) {
|
||||
while(!SerializedProperty.EqualContents(iterator, end)) {
|
||||
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
|
||||
if(!iterator.NextVisible(false)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
private static Type ResolveBaseType(Type fieldType) {
|
||||
if(fieldType.IsArray) {
|
||||
return fieldType.GetElementType();
|
||||
}
|
||||
if(fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(List<>)) {
|
||||
return fieldType.GetGenericArguments()[0];
|
||||
}
|
||||
return fieldType;
|
||||
}
|
||||
|
||||
private static Type[] GetConcreteTypes(Type baseType) {
|
||||
if(TypeCache.TryGetValue(baseType, out var cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
||||
.SelectMany(assembly => {
|
||||
try {
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch {
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
})
|
||||
.Where(type => baseType.IsAssignableFrom(type)
|
||||
&& !type.IsAbstract
|
||||
&& !type.IsInterface
|
||||
&& !typeof(UnityEngine.Object).IsAssignableFrom(type)
|
||||
&& type.GetConstructor(Type.EmptyTypes) != null)
|
||||
.OrderBy(type => type.Name)
|
||||
.ToArray();
|
||||
TypeCache[baseType] = types;
|
||||
return types;
|
||||
}
|
||||
|
||||
private static GUIContent ResolveDisplayLabel(SerializedProperty property, GUIContent fallback) {
|
||||
var value = property.managedReferenceValue;
|
||||
if(value is not IEncounter) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var definitionProp = value.GetType().GetProperty("EncounterDefinition");
|
||||
var definition = definitionProp?.GetValue(value);
|
||||
var nameField = definition?.GetType().GetField("name");
|
||||
var name = nameField?.GetValue(definition) as string;
|
||||
return string.IsNullOrEmpty(name) ? fallback : new GUIContent(name);
|
||||
}
|
||||
|
||||
private static Type GetCurrentType(SerializedProperty property) {
|
||||
var full = property.managedReferenceFullTypename;
|
||||
if(string.IsNullOrEmpty(full)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var space = full.IndexOf(' ');
|
||||
if(space < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var assembly = full.Substring(0, space);
|
||||
var typeName = full.Substring(space + 1);
|
||||
return Type.GetType($"{typeName}, {assembly}");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Editor/SubclassSelectorDrawer.cs.meta
Normal file
2
Editor/SubclassSelectorDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e00ec3e6bdfd024aa73689289578192
|
||||
29
LICENSE
29
LICENSE
@@ -1,18 +1,21 @@
|
||||
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
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
7
LICENSE.meta
Normal file
7
LICENSE.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 181516b3331a8824abeaa656991d77ee
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
167
README.md
167
README.md
@@ -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
7
README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 158607149986285429176c786851ed82
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Runtime.meta
Normal file
8
Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a3909887107f1f24aad1cd7db8a0bb28
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
68
Runtime/DialogLineLibrary.cs
Normal file
68
Runtime/DialogLineLibrary.cs
Normal 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
|
||||
}
|
||||
}
|
||||
2
Runtime/DialogLineLibrary.cs.meta
Normal file
2
Runtime/DialogLineLibrary.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 142d6e5b0f6a6cb41beddeae92b56fee
|
||||
36
Runtime/DialogLineRef.cs
Normal file
36
Runtime/DialogLineRef.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Runtime/EncounterContext.cs
Normal file
15
Runtime/EncounterContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/EncounterContext.cs.meta
Normal file
2
Runtime/EncounterContext.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d643c80eeffa65f4d804a795ba13bdf9
|
||||
48
Runtime/EncounterDialogOptionSet.cs
Normal file
48
Runtime/EncounterDialogOptionSet.cs
Normal 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
|
||||
}
|
||||
}
|
||||
3
Runtime/EncounterDialogOptionSet.cs.meta
Normal file
3
Runtime/EncounterDialogOptionSet.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c47caaa92bb94eeca3e47dd86fd010cf
|
||||
timeCreated: 1776587040
|
||||
33
Runtime/EncounterLink.cs
Normal file
33
Runtime/EncounterLink.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/EncounterLink.cs.meta
Normal file
2
Runtime/EncounterLink.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 60455ea141b903c4390dbcdc29b46f99
|
||||
18
Runtime/EncounterReference.cs
Normal file
18
Runtime/EncounterReference.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
3
Runtime/EncounterReference.cs.meta
Normal file
3
Runtime/EncounterReference.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0173ea7fbf1e45b1932694938ecd3058
|
||||
timeCreated: 1776508767
|
||||
67
Runtime/EncounterRegistry.cs
Normal file
67
Runtime/EncounterRegistry.cs
Normal 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
|
||||
}
|
||||
3
Runtime/EncounterRegistry.cs.meta
Normal file
3
Runtime/EncounterRegistry.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0dfee180882d49c9a3d4474f389d4905
|
||||
timeCreated: 1776584974
|
||||
49
Runtime/EncounterResolver.cs
Normal file
49
Runtime/EncounterResolver.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/EncounterResolver.cs.meta
Normal file
2
Runtime/EncounterResolver.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d3f07fa2d5f9804d8c75e7026566757
|
||||
63
Runtime/EncounterTable.cs
Normal file
63
Runtime/EncounterTable.cs
Normal 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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Runtime/EncounterTable.cs.meta
Normal file
3
Runtime/EncounterTable.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e480a30007b949679b8ca1e0e6088675
|
||||
timeCreated: 1776507230
|
||||
13
Runtime/EncountersCollection.cs
Normal file
13
Runtime/EncountersCollection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
3
Runtime/EncountersCollection.cs.meta
Normal file
3
Runtime/EncountersCollection.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96ab08e2592347f68b8ad2e6e8d45187
|
||||
timeCreated: 1776506926
|
||||
92
Runtime/IEncounter.cs
Normal file
92
Runtime/IEncounter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
3
Runtime/IEncounter.cs.meta
Normal file
3
Runtime/IEncounter.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d9c61fd5089459d8ef34cbbde0666b5
|
||||
timeCreated: 1776506880
|
||||
40
Runtime/IEncounterEvent.cs
Normal file
40
Runtime/IEncounterEvent.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Runtime/IEncounterEvent.cs.meta
Normal file
2
Runtime/IEncounterEvent.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e42b3f5f74428d944a68a320a821f0c9
|
||||
65
Runtime/IEncounterKind.cs
Normal file
65
Runtime/IEncounterKind.cs
Normal 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 {
|
||||
}
|
||||
}
|
||||
2
Runtime/IEncounterKind.cs.meta
Normal file
2
Runtime/IEncounterKind.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28cc80b36b63e6e44b7f1cfb6c57bf62
|
||||
46
Runtime/IRewardKind.cs
Normal file
46
Runtime/IRewardKind.cs
Normal 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 {
|
||||
}
|
||||
}
|
||||
2
Runtime/IRewardKind.cs.meta
Normal file
2
Runtime/IRewardKind.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2793c02adec79d4088031ab399c16e1
|
||||
19
Runtime/Jovian.EncounterSystem.asmdef
Normal file
19
Runtime/Jovian.EncounterSystem.asmdef
Normal 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
|
||||
}
|
||||
7
Runtime/Jovian.EncounterSystem.asmdef.meta
Normal file
7
Runtime/Jovian.EncounterSystem.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0452817d1bdb1084da85c56a64179c01
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
80
Runtime/QuestLog.cs
Normal file
80
Runtime/QuestLog.cs
Normal 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
2
Runtime/QuestLog.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74f83e6449bec1847bcb25cb5398a682
|
||||
123
Runtime/QuestProgress.cs
Normal file
123
Runtime/QuestProgress.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/QuestProgress.cs.meta
Normal file
2
Runtime/QuestProgress.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2eacdbd0e462b9b46b7f3cce64d26765
|
||||
22
Runtime/Reward.cs
Normal file
22
Runtime/Reward.cs
Normal 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
2
Runtime/Reward.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ce47d4bfb319877429589295ac214255
|
||||
13
Runtime/SubclassSelectorAttribute.cs
Normal file
13
Runtime/SubclassSelectorAttribute.cs
Normal 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 {
|
||||
}
|
||||
}
|
||||
2
Runtime/SubclassSelectorAttribute.cs.meta
Normal file
2
Runtime/SubclassSelectorAttribute.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef3578112ccfa3447b74712009145c75
|
||||
19
package.json
Normal file
19
package.json
Normal 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
7
package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 75b61f91daea15646839c76fcba9a1d7
|
||||
PackageManifestImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user