added encounter system

This commit is contained in:
Sebastian Bularca
2026-04-19 12:46:44 +02:00
parent c1b5d0e9e0
commit 8861bdc5eb
94 changed files with 2581 additions and 13 deletions

View File

@@ -115,6 +115,11 @@ MonoBehaviour:
m_ReadOnly: 0 m_ReadOnly: 0
m_SerializedLabels: [] m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0 FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 7a17d50d1abe3764695c6cd9598487ca
m_Address: EncounterRegistry
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 7b5e9961dadecea4bba3be6de61909f3 - m_GUID: 7b5e9961dadecea4bba3be6de61909f3
m_Address: CalendarSettings m_Address: CalendarSettings
m_ReadOnly: 0 m_ReadOnly: 0

View File

@@ -26,7 +26,8 @@ namespace Nox.Game {
private readonly PlayModeSettings bootstrapSettings; private readonly PlayModeSettings bootstrapSettings;
private readonly GameDataState gameDataState; private readonly GameDataState gameDataState;
private readonly ISaveSystem saveSystem; private readonly ISaveSystem saveSystem;
private PartyDefinition partyDefinition; private readonly PartyDefinition partyDefinition;
private readonly AdventureSettings adventureSettings;
private AdventureData adventureData; private AdventureData adventureData;
private AdventureModePrefabs scenePrefabs; private AdventureModePrefabs scenePrefabs;
private ICameraController cameraController; private ICameraController cameraController;
@@ -38,7 +39,6 @@ namespace Nox.Game {
private AdventureView adventureView; private AdventureView adventureView;
private ZoneSystem zoneSystem; private ZoneSystem zoneSystem;
private GuiReferences guiReferences; private GuiReferences guiReferences;
private AdventureSettings adventureSettings;
private TimeHandler timeHandler; private TimeHandler timeHandler;
private PartyInventoryHandler partyInventoryHandler; private PartyInventoryHandler partyInventoryHandler;
private PartyGuiView partyGuiView; private PartyGuiView partyGuiView;

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d6216fd41db9494aaa6c127d9d790b93
timeCreated: 1776506857

View File

@@ -0,0 +1,7 @@
using System;
namespace Nox.Game {
public class EncounterHandler {
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 523274f9158f453dbfac02601a77c3f7
timeCreated: 1776506833

View File

@@ -0,0 +1,19 @@
using Nox.Game.UI;
namespace Nox.Game {
public class EncounterView : IMenuView{
public void Initialize() {
throw new System.NotImplementedException();
}
public void Show() {
throw new System.NotImplementedException();
}
public void Hide() {
throw new System.NotImplementedException();
}
public void Tick() {
throw new System.NotImplementedException();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b69490c9f1d8471a84b4594d1b1be117
timeCreated: 1776590016

View File

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

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0dfee180882d49c9a3d4474f389d4905, type: 3}
m_Name: EncounterRegistry
m_EditorClassIdentifier: Assembly-CSharp::Nox.Game.EncounterRegistry
encounterCollections:
- {fileID: 11400000, guid: b3c3371ae34b4e34ea57a013b5125022, type: 2}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7a17d50d1abe3764695c6cd9598487ca
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 142d6e5b0f6a6cb41beddeae92b56fee, type: 3}
m_Name: DialogLineLibrary
m_EditorClassIdentifier: Jovian.EncounterSystem::Jovian.EncounterSystem.DialogLineLibrary
lines:
- id: test_you_die
text: Click me and see what happens. MIght all your dreams com true?
- id: click_to_continue
text: This is a test like that should you click it, it will continue and record
quest progress
- id: right_choice
text: You have chose right, padwan, here is 4 RON!

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 11b94daa76442834198b68996afe0013
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,29 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c47caaa92bb94eeca3e47dd86fd010cf, type: 3}
m_Name: Dialog_Set_0
m_EditorClassIdentifier: Assembly-CSharp::Nox.Game.EncounterDialogOptionSet
id: Dialog_Set_1
library: {fileID: 11400000, guid: 11b94daa76442834198b68996afe0013, type: 2}
options:
- text:
id: click_to_continue
inlineText:
events:
- rid: 1352971465325281416
references:
version: 2
RefIds:
- rid: 1352971465325281416
type: {class: LogEvent, ns: Jovian.EncounterSystem, asm: Jovian.EncounterSystem}
data:
message: An thus, you continue...

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7bc554ae0760bbb4796a1b3acef22cb3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,38 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c47caaa92bb94eeca3e47dd86fd010cf, type: 3}
m_Name: Dialog_Set_1
m_EditorClassIdentifier: Assembly-CSharp::Nox.Game.EncounterDialogOptionSet
id: Dialog_Set_1
library: {fileID: 11400000, guid: 11b94daa76442834198b68996afe0013, type: 2}
options:
- text:
id: test_you_die
inlineText:
events:
- rid: 1352971465325281414
- text:
id: right_choice
inlineText:
events:
- rid: 1352971465325281412
references:
version: 2
RefIds:
- rid: 1352971465325281412
type: {class: GiveRewardEvent, ns: Jovian.EncounterSystem, asm: Jovian.EncounterSystem}
data:
reward: {fileID: 11400000, guid: 2c02212ce09c5a246a8fe11a5253bfd4, type: 2}
- rid: 1352971465325281414
type: {class: LogEvent, ns: Jovian.EncounterSystem, asm: Jovian.EncounterSystem}
data:
message: You died! HAhahAhaha!

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9496570aa3d05624a9b8bbbf6009c453
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,57 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e480a30007b949679b8ca1e0e6088675, type: 3}
m_Name: TestEncounterTable
m_EditorClassIdentifier: Assembly-CSharp::Nox.Game.EncounterTable
id: TestEncounterTable
encounters:
- <EncounterDefinition>k__BackingField:
internalId: adce0a09-6402-4c2e-b24a-db7c9c67e3e5
id: test_quest_1
name: Test Quest Stage I
description: An encounter like no other which leads to another encounter like
no other
<EncounterProperties>k__BackingField:
difficulty: 0
<EncounterVisuals>k__BackingField:
icon: {fileID: 21300000, guid: ea02ea44fa86ee445be0f7ca82098b75, type: 3}
encounterColor: {r: 0, g: 0, b: 0, a: 0}
encounterArt: {fileID: 21300000, guid: a9c4c7681315e25419b9381d28aa9d80, type: 3}
<EncounterDialogOptionSet>k__BackingField: {fileID: 11400000, guid: 9496570aa3d05624a9b8bbbf6009c453, type: 2}
<Kind>k__BackingField:
rid: 1352971465325281411
- <EncounterDefinition>k__BackingField:
internalId: adce0a09-6402-4c2e-b24a-db7c9c67e3e5
id: test_quest_2
name: Test Quest Stage II
description: An encounter like no other which should be now completed
<EncounterProperties>k__BackingField:
difficulty: 0
<EncounterVisuals>k__BackingField:
icon: {fileID: 21300000, guid: ea02ea44fa86ee445be0f7ca82098b75, type: 3}
encounterColor: {r: 0, g: 0, b: 0, a: 0}
encounterArt: {fileID: 21300000, guid: 819d7a244820ad84585a1de7566bf9d0, type: 3}
<EncounterDialogOptionSet>k__BackingField: {fileID: 11400000, guid: 9496570aa3d05624a9b8bbbf6009c453, type: 2}
<Kind>k__BackingField:
rid: -2
references:
version: 2
RefIds:
- rid: -2
type: {class: , ns: , asm: }
- rid: 1352971465325281411
type: {class: QuestKind, ns: Jovian.EncounterSystem, asm: Jovian.EncounterSystem}
data:
nextEncounter:
table: {fileID: 11400000}
internalId: adce0a09-6402-4c2e-b24a-db7c9c67e3e5
questTitle:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 396a5409178bf0d4b938094eefe22cca
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -0,0 +1,26 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: ce47d4bfb319877429589295ac214255, type: 3}
m_Name: Reward
m_EditorClassIdentifier: Jovian.EncounterSystem::Jovian.EncounterSystem.Reward
id: gold
displayName: All Ze Money
kind:
rid: 1352971465325281413
references:
version: 2
RefIds:
- rid: 1352971465325281413
type: {class: CurrencyRewardKind, ns: Jovian.EncounterSystem, asm: Jovian.EncounterSystem}
data:
currencyId: gold
amount: 100

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2c02212ce09c5a246a8fe11a5253bfd4
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 96ab08e2592347f68b8ad2e6e8d45187, type: 3}
m_Name: TestEncountersCollection
m_EditorClassIdentifier: Assembly-CSharp::Nox.Game.EncountersCollection
encounterTables:
- {fileID: 11400000, guid: 396a5409178bf0d4b938094eefe22cca, type: 2}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b3c3371ae34b4e34ea57a013b5125022
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -109,3 +109,20 @@ Assets/Code/
- Factory pattern for character/party creation - Factory pattern for character/party creation
- Action delegates for UI event communication (e.g., `menuGameStateData.startGameRequests += handler`) - Action delegates for UI event communication (e.g., `menuGameStateData.startGameRequests += handler`)
- Mixed async patterns: both `async Task` and `IEnumerator` coroutines for async operations - Mixed async patterns: both `async Task` and `IEnumerator` coroutines for async operations
## Planning Docs Convention
Non-trivial features are planned in `docs/plans/` as paired markdown files: a design doc and an implementation doc, both prefixed with the date. Examples: `2026-04-05-ingame-logging-design.md` + `2026-04-05-ingame-logging-implementation.md`, `2026-04-06-popup-system-design.md` + `2026-04-06-popup-system-implementation.md`. When starting a new feature, look here first for existing context, and follow the same pair-of-docs pattern for new work.
## In-House Jovian Packages
Several packages under `Packages/` are first-party code maintained alongside the game, not third-party dependencies. Before adding new capabilities, check whether one of these already provides what you need:
- `Jovian.SaveSystem` — JSON persistence, used by `GamePersistenceController`
- `Jovian.Calendar` — custom in-game calendar system
- `Jovian.InGameLogging` — runtime logging surface
- `Jovian.EncounterSystem` — data-driven encounter authoring, `IEncounterKind` polymorphic payloads, `EncounterLink` cross-table refs, `QuestProgress` gated progression + `QuestLog`. Designer browser at `Jovian → Encounters → Encounter Browser`.
- `Jovian.PopupSystem` — popup/dialog UI
- `Jovian.ZoneSystem` — zone/region management
- `Jovian.Logger` — developer logging
- `Jovian.InspectorTools` / `JovianUtilities` — editor and general utilities

View File

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

View File

@@ -0,0 +1,106 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Id dropdown (library inherited from the owning asset's <c>library</c> field) + inline fallback + resolved preview.</summary>
[CustomPropertyDrawer(typeof(DialogLineRef))]
public class DialogLineRefDrawer : PropertyDrawer {
private const string NonePlaceholder = "<none>";
private const string EmptyLibraryPlaceholder = "<set library on the parent asset>";
private const float PreviewHeight = 32f;
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
var idProp = property.FindPropertyRelative("id");
var inlineProp = property.FindPropertyRelative("inlineText");
var library = ResolveLibrary(property);
EditorGUI.BeginProperty(position, label, property);
var lineHeight = EditorGUIUtility.singleLineHeight;
var spacing = EditorGUIUtility.standardVerticalSpacing;
var idRect = new Rect(position.x, position.y, position.width, lineHeight);
DrawIdPicker(idRect, library, idProp, label);
var inlineRect = new Rect(
position.x,
position.y + lineHeight + spacing,
position.width,
lineHeight * 2);
EditorGUI.PropertyField(inlineRect, inlineProp, new GUIContent("Inline"));
var previewRect = new Rect(
position.x,
position.y + lineHeight + spacing + lineHeight * 2 + spacing,
position.width,
PreviewHeight);
DrawPreview(previewRect, library, idProp, inlineProp);
EditorGUI.EndProperty();
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var lineHeight = EditorGUIUtility.singleLineHeight;
var spacing = EditorGUIUtility.standardVerticalSpacing;
// id + (inline 2 lines) + preview
return lineHeight + spacing + lineHeight * 2 + spacing + PreviewHeight;
}
private static DialogLineLibrary ResolveLibrary(SerializedProperty property) {
var libraryProp = property.serializedObject.FindProperty("library");
return libraryProp?.objectReferenceValue as DialogLineLibrary;
}
private static void DrawIdPicker(Rect rect, DialogLineLibrary library, SerializedProperty idProp, GUIContent label) {
if(library == null || library.lines == null || library.lines.Count == 0) {
using(new EditorGUI.DisabledScope(true)) {
EditorGUI.Popup(rect, label.text, 0, new[] { EmptyLibraryPlaceholder });
}
return;
}
var count = library.lines.Count;
var ids = new string[count + 1];
var names = new string[count + 1];
ids[0] = string.Empty;
names[0] = NonePlaceholder;
var currentIndex = 0;
for(int i = 0; i < count; i++) {
var line = library.lines[i];
var id = line?.id ?? string.Empty;
ids[i + 1] = id;
names[i + 1] = string.IsNullOrEmpty(id) ? $"<unnamed {i}>" : id;
if(id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
currentIndex = i + 1;
}
}
var newIndex = EditorGUI.Popup(rect, label.text, currentIndex, names);
if(newIndex != currentIndex) {
idProp.stringValue = ids[newIndex];
}
}
private static void DrawPreview(Rect rect, DialogLineLibrary library, SerializedProperty idProp, SerializedProperty inlineProp) {
var id = idProp.stringValue;
var inline = inlineProp.stringValue;
string resolved = null;
if(library != null && !string.IsNullOrEmpty(id)) {
resolved = library.Resolve(id);
}
if(string.IsNullOrEmpty(resolved)) {
resolved = inline;
}
var style = new GUIStyle(EditorStyles.helpBox) {
fontStyle = FontStyle.Italic,
wordWrap = true
};
var label = string.IsNullOrEmpty(resolved) ? "<no text will display>" : resolved;
EditorGUI.LabelField(rect, new GUIContent("Preview: " + label), style);
}
}
}

View File

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

View File

@@ -0,0 +1,394 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Browser for every encounter across all tables. Search + kind filter + detail pane. Quest chains render as a tree rooted at the first step.</summary>
public class EncounterBrowserWindow : EditorWindow {
private const string AllKinds = "All";
private class Record {
public EncounterTable table;
public int index;
public IEncounter encounter;
}
private readonly List<Record> allRecords = new();
private string searchText = string.Empty;
private string kindFilter = AllKinds;
private readonly Dictionary<IEncounter, List<ValidationIssue>> issuesByEncounter = new();
private TreeView treeView;
private VisualElement detailPane;
private ToolbarMenu kindDropdown;
[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);
treeView = new TreeView {
makeItem = MakeRow,
bindItem = BindRow,
fixedItemHeight = 22,
selectionType = SelectionType.Single
};
treeView.selectionChanged += OnSelectionChanged;
treeView.style.flexGrow = 1f;
split.Add(treeView);
detailPane = new ScrollView(ScrollViewMode.Vertical) {
style = { paddingLeft = 8, paddingTop = 8, paddingRight = 8, flexGrow = 1f }
};
ShowEmptyDetail();
split.Add(detailPane);
}
private static VisualElement MakeRow() {
var row = new VisualElement {
style = {
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
paddingLeft = 6,
paddingRight = 6,
height = 22
}
};
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 = treeView.GetItemDataForIndex<Record>(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() {
var filtered = allRecords.Where(Matches).ToList();
var items = BuildTreeItems(filtered);
if(treeView != null) {
treeView.SetRootItems(items);
treeView.Rebuild();
treeView.ClearSelection();
treeView.ExpandAll();
}
ShowEmptyDetail();
}
private static List<TreeViewItemData<Record>> BuildTreeItems(List<Record> records) {
var byEncounter = new Dictionary<IEncounter, Record>();
foreach(var r in records) {
if(r.encounter != null) {
byEncounter[r.encounter] = r;
}
}
// Any encounter that is a `nextEncounter` target of another record in the filtered set
// becomes a non-root (rendered as a child), not a top-level item.
var nonRoot = new HashSet<IEncounter>();
foreach(var r in records) {
if(r.encounter?.Kind is not QuestKind quest) {
continue;
}
var next = quest.nextEncounter.Resolve();
if(next != null && byEncounter.ContainsKey(next)) {
nonRoot.Add(next);
}
}
var result = new List<TreeViewItemData<Record>>();
var uid = 0;
foreach(var r in records) {
if(r.encounter != null && nonRoot.Contains(r.encounter)) {
continue;
}
var children = BuildChainChildren(r, byEncounter, ref uid);
result.Add(new TreeViewItemData<Record>(uid++, r, children));
}
return result;
}
private static List<TreeViewItemData<Record>> BuildChainChildren(Record root, Dictionary<IEncounter, Record> byEncounter, ref int uid) {
var children = new List<TreeViewItemData<Record>>();
if(root.encounter?.Kind is not QuestKind) {
return children;
}
var current = root.encounter;
var visited = new HashSet<IEncounter> { current };
while(current?.Kind is QuestKind quest) {
var next = quest.nextEncounter.Resolve();
if(next == null || !visited.Add(next)) {
break;
}
if(!byEncounter.TryGetValue(next, out var nextRecord)) {
break;
}
children.Add(new TreeViewItemData<Record>(uid++, nextRecord));
current = next;
}
return children;
}
private bool Matches(Record record) {
var kindName = record.encounter?.Kind?.GetType().Name ?? string.Empty;
if(kindFilter != AllKinds && kindName != kindFilter) {
return false;
}
if(string.IsNullOrEmpty(searchText)) {
return true;
}
var needle = searchText.ToLowerInvariant();
var definition = record.encounter?.EncounterDefinition;
return Contains(definition?.id, needle)
|| Contains(definition?.name, needle)
|| Contains(definition?.description, needle);
}
private static bool Contains(string source, string needle) {
return !string.IsNullOrEmpty(source) && source.ToLowerInvariant().Contains(needle);
}
private void OnSelectionChanged(IEnumerable<object> selection) {
var record = selection.OfType<Record>().FirstOrDefault();
if(record == null) {
ShowEmptyDetail();
return;
}
detailPane.Clear();
var serializedObject = new SerializedObject(record.table);
var encountersProp = serializedObject.FindProperty(nameof(EncounterTable.encounters));
var elementProp = encountersProp.GetArrayElementAtIndex(record.index);
var header = new Label($"{record.table.name} → [{record.index}]") {
style = {
unityFontStyleAndWeight = FontStyle.Bold,
marginBottom = 6,
color = new StyleColor(new Color(0.75f, 0.75f, 0.75f))
}
};
detailPane.Add(header);
elementProp.isExpanded = true;
var field = new PropertyField(elementProp);
field.Bind(serializedObject);
detailPane.Add(field);
AddChainPreviewIfQuest(record);
}
private void AddChainPreviewIfQuest(Record record) {
if(record.encounter?.Kind is not QuestKind questKind) {
return;
}
var predecessor = FindPredecessor(record.encounter.EncounterDefinition?.internalId);
var next = questKind.nextEncounter.Resolve();
var predName = predecessor?.encounter.EncounterDefinition?.name ?? "—";
var currName = record.encounter.EncounterDefinition?.name ?? "<unnamed>";
var nextName = next?.EncounterDefinition?.name ?? "—";
var chainLabel = new Label($"Chain: {predName} ← {currName} → {nextName}") {
style = {
marginTop = 12,
paddingTop = 6,
paddingBottom = 6,
paddingLeft = 6,
paddingRight = 6,
backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.2f, 0.4f)),
unityFontStyleAndWeight = FontStyle.Bold
}
};
detailPane.Add(chainLabel);
}
private Record FindPredecessor(string targetId) {
if(string.IsNullOrEmpty(targetId)) {
return null;
}
foreach(var record in allRecords) {
if(record.encounter?.Kind is not QuestKind kind) {
continue;
}
var next = kind.nextEncounter.Resolve();
if(next?.EncounterDefinition?.internalId == targetId) {
return record;
}
}
return null;
}
private void ShowEmptyDetail() {
if(detailPane == null) {
return;
}
detailPane.Clear();
var empty = new Label("Select an encounter to edit.") {
style = { color = new StyleColor(Color.gray), marginTop = 8 }
};
detailPane.Add(empty);
}
}
}

View File

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

View File

@@ -0,0 +1,76 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Label list elements with the option's text id (fallback: inline text preview, then default).</summary>
[CustomPropertyDrawer(typeof(EncounterDialogOption))]
public class EncounterDialogOptionDrawer : PropertyDrawer {
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
var displayLabel = ResolveLabel(property, label);
var foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, displayLabel, true);
if(!property.isExpanded) {
return;
}
EditorGUI.indentLevel++;
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
var h = EditorGUI.GetPropertyHeight(iterator, true);
var r = new Rect(position.x, y, position.width, h);
EditorGUI.PropertyField(r, iterator, true);
y += h + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
EditorGUI.indentLevel--;
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var height = EditorGUIUtility.singleLineHeight;
if(!property.isExpanded) {
return height;
}
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
return height;
}
private static GUIContent ResolveLabel(SerializedProperty property, GUIContent fallback) {
var textProp = property.FindPropertyRelative("text");
if(textProp == null) {
return fallback;
}
var id = textProp.FindPropertyRelative("id")?.stringValue;
if(!string.IsNullOrEmpty(id)) {
return new GUIContent(id);
}
var inline = textProp.FindPropertyRelative("inlineText")?.stringValue;
if(!string.IsNullOrEmpty(inline)) {
var preview = inline.Length > 40 ? inline.Substring(0, 40) + "…" : inline;
return new GUIContent(preview);
}
return fallback;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5636dc9f15ce02c4ca189c3d5f46eb34

View File

@@ -0,0 +1,77 @@
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Label list elements with the encounter id (fallback: name, then default).</summary>
[CustomPropertyDrawer(typeof(Encounter))]
public class EncounterDrawer : PropertyDrawer {
private const string DefinitionBackingField = "<EncounterDefinition>k__BackingField";
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
var displayLabel = ResolveLabel(property, label);
var foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, displayLabel, true);
if(!property.isExpanded) {
return;
}
EditorGUI.indentLevel++;
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
var h = EditorGUI.GetPropertyHeight(iterator, true);
var r = new Rect(position.x, y, position.width, h);
EditorGUI.PropertyField(r, iterator, true);
y += h + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
EditorGUI.indentLevel--;
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var height = EditorGUIUtility.singleLineHeight;
if(!property.isExpanded) {
return height;
}
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
return height;
}
private static GUIContent ResolveLabel(SerializedProperty property, GUIContent fallback) {
var definition = property.FindPropertyRelative(DefinitionBackingField);
if(definition == null) {
return fallback;
}
var id = definition.FindPropertyRelative("id")?.stringValue;
if(!string.IsNullOrEmpty(id)) {
return new GUIContent(id);
}
var name = definition.FindPropertyRelative("name")?.stringValue;
if(!string.IsNullOrEmpty(name)) {
return new GUIContent(name);
}
return fallback;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,296 @@
using System.Collections.Generic;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
public enum ValidationSeverity {
Warning,
Error
}
public class ValidationIssue {
public Object asset;
public Encounter encounter;
public string path;
public ValidationSeverity severity;
public string message;
}
/// <summary>Project-wide scan of encounter tables and rewards. Runs on demand, no caching.</summary>
public static class EncounterValidator {
public static List<ValidationIssue> ValidateProject() {
var issues = new List<ValidationIssue>();
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;
}
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 == null) {
continue;
}
ValidateDialogLineRef(optionSet, encounter, $"options[{o}].text", option.text, issues);
if(option.events == null) {
continue;
}
for(int e = 0; e < option.events.Count; e++) {
var evt = option.events[e];
if(evt == null) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = $"options[{o}].events[{e}]",
severity = ValidationSeverity.Warning,
message = "Null event entry (type may have been renamed/deleted)."
});
continue;
}
if(evt is GiveRewardEvent give && give.reward == null) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = $"options[{o}].events[{e}].reward",
severity = ValidationSeverity.Warning,
message = "GiveRewardEvent has no Reward asset assigned."
});
}
}
}
}
private static void ValidateDialogLineRef(EncounterDialogOptionSet optionSet, Encounter encounter, string path, DialogLineRef lineRef, List<ValidationIssue> issues) {
var library = optionSet.library;
var hasId = !string.IsNullOrEmpty(lineRef.id);
var hasInline = !string.IsNullOrEmpty(lineRef.inlineText);
if(!hasId && !hasInline) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = path,
severity = ValidationSeverity.Warning,
message = "Dialog line is empty (no id and no inline text)."
});
return;
}
if(hasId && library == null) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = path,
severity = ValidationSeverity.Error,
message = $"DialogLineRef references id '{lineRef.id}' but the option set has no library assigned."
});
return;
}
if(hasId && library != null && string.IsNullOrEmpty(library.Resolve(lineRef.id))) {
issues.Add(new ValidationIssue {
asset = optionSet,
encounter = encounter,
path = path,
severity = ValidationSeverity.Error,
message = $"DialogLineRef id '{lineRef.id}' not found in library '{library.name}'."
});
}
}
private static void ValidateReward(Reward reward, List<ValidationIssue> issues) {
if(reward == null) {
return;
}
if(reward.kind == null) {
issues.Add(new ValidationIssue {
asset = reward,
path = "kind",
severity = ValidationSeverity.Warning,
message = "Reward.kind is null — pick a reward kind in the inspector."
});
}
if(string.IsNullOrEmpty(reward.id)) {
issues.Add(new ValidationIssue {
asset = reward,
path = "id",
severity = ValidationSeverity.Warning,
message = "Reward has no id."
});
}
}
private static List<T> FindAssetsOfType<T>() where T : Object {
var result = new List<T>();
var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);
foreach(var guid in guids) {
var path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
if(asset != null) {
result.Add(asset);
}
}
return result;
}
}
public static class EncounterValidatorMenu {
[MenuItem("Jovian/Encounters/Validate All")]
public static void ValidateAll() {
var issues = EncounterValidator.ValidateProject();
if(issues.Count == 0) {
Debug.Log("[EncounterValidator] No issues found.");
return;
}
int errors = 0;
int warnings = 0;
foreach(var issue in issues) {
if(issue.severity == ValidationSeverity.Error) {
errors++;
}
else {
warnings++;
}
}
Debug.Log($"[EncounterValidator] {issues.Count} issue(s) found — {errors} error(s), {warnings} warning(s). Click any log row to ping the offending asset.");
foreach(var issue in issues) {
var assetName = issue.asset != null ? issue.asset.name : "<null>";
var message = $"[EncounterValidator] {assetName} · {issue.path} — {issue.message}";
if(issue.severity == ValidationSeverity.Error) {
Debug.LogError(message, issue.asset);
}
else {
Debug.LogWarning(message, issue.asset);
}
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jovian.EncounterSystem;
using UnityEditor;
using UnityEngine;
namespace Jovian.EncounterSystem.Editor {
/// <summary>Concrete-type dropdown for <c>[SerializeReference]</c> fields, arrays, and list elements.</summary>
[CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
public class SubclassSelectorDrawer : PropertyDrawer {
private static readonly Dictionary<Type, Type[]> TypeCache = new();
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
if(property.propertyType != SerializedPropertyType.ManagedReference) {
EditorGUI.LabelField(position, label.text, "[SubclassSelector] requires [SerializeReference]");
return;
}
var baseType = ResolveBaseType(fieldInfo.FieldType);
var concreteTypes = GetConcreteTypes(baseType);
var currentType = GetCurrentType(property);
var currentIndex = Array.IndexOf(concreteTypes, currentType);
var names = new string[concreteTypes.Length + 1];
names[0] = "<None>";
for(int i = 0; i < concreteTypes.Length; i++) {
names[i + 1] = concreteTypes[i].Name;
}
var displayLabel = ResolveDisplayLabel(property, label);
var headerRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
var labelRect = new Rect(headerRect.x, headerRect.y, EditorGUIUtility.labelWidth, headerRect.height);
var popupRect = new Rect(headerRect.x + EditorGUIUtility.labelWidth, headerRect.y, headerRect.width - EditorGUIUtility.labelWidth, headerRect.height);
EditorGUI.LabelField(labelRect, displayLabel);
var newIndex = EditorGUI.Popup(popupRect, currentIndex + 1, names);
if(newIndex != currentIndex + 1) {
property.managedReferenceValue = newIndex == 0 ? null : Activator.CreateInstance(concreteTypes[newIndex - 1]);
property.serializedObject.ApplyModifiedProperties();
}
if(property.managedReferenceValue == null) {
return;
}
EditorGUI.indentLevel++;
var y = position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
var h = EditorGUI.GetPropertyHeight(iterator, true);
var r = new Rect(position.x, y, position.width, h);
EditorGUI.PropertyField(r, iterator, true);
y += h + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
EditorGUI.indentLevel--;
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
var height = EditorGUIUtility.singleLineHeight;
if(property.propertyType != SerializedPropertyType.ManagedReference || property.managedReferenceValue == null) {
return height;
}
var iterator = property.Copy();
var end = iterator.GetEndProperty();
if(iterator.NextVisible(true)) {
while(!SerializedProperty.EqualContents(iterator, end)) {
height += EditorGUI.GetPropertyHeight(iterator, true) + EditorGUIUtility.standardVerticalSpacing;
if(!iterator.NextVisible(false)) {
break;
}
}
}
return height;
}
private static Type ResolveBaseType(Type fieldType) {
if(fieldType.IsArray) {
return fieldType.GetElementType();
}
if(fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(List<>)) {
return fieldType.GetGenericArguments()[0];
}
return fieldType;
}
private static Type[] GetConcreteTypes(Type baseType) {
if(TypeCache.TryGetValue(baseType, out var cached)) {
return cached;
}
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(assembly => {
try {
return assembly.GetTypes();
}
catch {
return Array.Empty<Type>();
}
})
.Where(type => baseType.IsAssignableFrom(type)
&& !type.IsAbstract
&& !type.IsInterface
&& !typeof(UnityEngine.Object).IsAssignableFrom(type)
&& type.GetConstructor(Type.EmptyTypes) != null)
.OrderBy(type => type.Name)
.ToArray();
TypeCache[baseType] = types;
return types;
}
private static GUIContent ResolveDisplayLabel(SerializedProperty property, GUIContent fallback) {
var value = property.managedReferenceValue;
if(value is not IEncounter) {
return fallback;
}
var definitionProp = value.GetType().GetProperty("EncounterDefinition");
var definition = definitionProp?.GetValue(value);
var nameField = definition?.GetType().GetField("name");
var name = nameField?.GetValue(definition) as string;
return string.IsNullOrEmpty(name) ? fallback : new GUIContent(name);
}
private static Type GetCurrentType(SerializedProperty property) {
var full = property.managedReferenceFullTypename;
if(string.IsNullOrEmpty(full)) {
return null;
}
var space = full.IndexOf(' ');
if(space < 0) {
return null;
}
var assembly = full.Substring(0, space);
var typeName = full.Substring(space + 1);
return Type.GetType($"{typeName}, {assembly}");
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,186 @@
# Jovian Encounter System
Data-driven encounter authoring for Unity. One composable `Encounter` type carrying a polymorphic `IEncounterKind` payload, dialog options with designer-authored events, cross-table quest chaining, and gated quest progression that serialises cleanly.
## Package Structure
```
Packages/com.jovian.encounter-system/
├── Runtime/
│ ├── IEncounter.cs ← interface + Encounter + definition/visuals/properties
│ ├── IEncounterKind.cs ← marker interface + Combat/Quest/Social/Puzzle/...
│ ├── EncounterTable.cs ← ScriptableObject: list of encounters + roll helpers
│ ├── EncountersCollection.cs ← ScriptableObject: group of tables
│ ├── EncounterDialogOptionSet.cs ← ScriptableObject: shared option list (auto-renames to id)
│ ├── DialogLineLibrary.cs ← ScriptableObject: id → text registry for reusable lines
│ ├── DialogLineRef.cs ← struct: library+id reference with inline fallback
│ ├── EncounterLink.cs ← cross-table reference (table + internalId)
│ ├── EncounterReference.cs ← MonoBehaviour scaffold wiring common UI fields
│ ├── EncounterRegistry.cs ← id → encounter lookup cache
│ ├── SubclassSelectorAttribute.cs← attribute powering the concrete-type dropdown
│ ├── IEncounterEvent.cs ← event interface + ChainTo/StartCombat/Log/GiveReward starters
│ ├── Reward.cs ← ScriptableObject: reward asset (id + displayName + icon + kind)
│ ├── IRewardKind.cs ← marker interface + Currency/Item/Experience/Unlockable/Other
│ ├── EncounterContext.cs ← per-resolution data bag
│ ├── EncounterResolver.cs ← Register<T>/Resolve dispatch by concrete event type
│ ├── QuestProgress.cs ← gated progression + QuestStarted/Advanced/Completed events
│ └── QuestLog.cs ← chronological serialisable record of quest events
└── Editor/
├── SubclassSelectorDrawer.cs ← dropdown + inline children for [SerializeReference] fields
├── EncounterLinkDrawer.cs ← two-row table + encounter picker
├── DialogLineRefDrawer.cs ← library+id picker with inline fallback and live preview
├── EncounterDrawer.cs ← list element label shows encounter id
├── EncounterValidator.cs ← project-wide scan + "Validate All" menu + browser badges
└── EncounterBrowserWindow.cs ← Jovian → Encounters → Encounter Browser
```
## Quick Start
1. Add the package to your project (local package in `Packages/`).
2. **Create a table**: `Assets → Create → Jovian → Encounter System → Encounter Table`.
3. **Create a collection**: `Assets → Create → Jovian → Encounter System → Encounters Collection`, drag tables into its array.
4. In the table inspector, add encounters to the list. For each: pick the **kind** in the dropdown, fill in shared fields (id/name/description), and populate the kind-specific payload.
5. For a quest chain, set `QuestKind.nextEncounter` on each step — pick the target table, then pick the encounter inside it.
6. **Browse everything**: `Jovian → Encounters → Encounter Browser` for a searchable, filterable list view.
## Key Concepts
### `Encounter` is one class; `IEncounterKind` is where types live
A single concrete `Encounter` carries shared fields (`EncounterDefinition`, `EncounterProperties`, `EncounterVisuals`, `EncounterDialogOptionSet`). Its `Kind` property is a `[SerializeReference, SubclassSelector]` polymorphic slot. Adding a new kind is one small class:
```csharp
[Serializable]
public class MerchantKind : IEncounterKind {
public string shopInventoryId;
public float priceMultiplier = 1f;
}
```
The editor dropdown picks it up automatically via reflection. No base class to extend, no subclass of `Encounter` to write.
### Dialog option events are designer-authored, code-dispatched
Each `EncounterDialogOption` holds an ordered `List<IEncounterEvent>` (also `[SerializeReference, SubclassSelector]`). Events are data only — what happens when the option is chosen is determined by handlers registered on `EncounterResolver`:
```csharp
var resolver = new EncounterResolver();
resolver.Register<StartCombatEvent>((evt, ctx) => combatSystem.Start(evt.combatEncounterId));
resolver.Register<ChainToEncounterEvent>((evt, ctx) => encounterFlow.GoTo(evt.nextEncounterId));
resolver.Register<LogEvent>((evt, _) => Debug.Log(evt.message));
// When the player picks an option:
resolver.Resolve(chosenOption.events, new EncounterContext(currentEncounter));
```
### Rewards are reusable assets, referenced by events
`Reward` is a ScriptableObject — one file per reusable reward (`10_Gold.asset`, `Rusty_Sword.asset`, `Unlock_Riverfort.asset`). Each asset has shared metadata (id, display name, icon) plus a polymorphic `IRewardKind` payload:
```csharp
[Serializable]
public class PotionOfHealingRewardKind : IRewardKind {
public int healAmount = 50;
}
```
To grant rewards, drop `GiveRewardEvent`s onto a dialog option and assign a `Reward` asset to each. Multiple rewards = multiple events — the resolver iterates them in order.
Game-side wiring applies the reward:
```csharp
resolver.Register<GiveRewardEvent>((evt, ctx) => rewardApplier.Apply(evt.reward, ctx));
```
`rewardApplier` lives in game code — the package ships only the data types.
### Reusable dialog lines via `DialogLineLibrary` + `DialogLineRef`
A single `DialogLineLibrary` asset (or a handful split by topic) holds `{ id → text }` entries. Every `EncounterDialogOption.text` is a `DialogLineRef` struct that resolves in this order:
1. If the library reference + id resolves, use that.
2. Otherwise fall back to `inlineText`.
The drawer shows all three inputs plus a live preview of the final text — WYSIWYG survives. Designers prototype inline and promote common lines to the library later without changing the field's type.
```csharp
resolver.Register<SomeEvent>((evt, ctx) => {
var text = option.text.Resolve(); // library lookup, inline fallback, null if both empty
});
```
### Cross-table references via `EncounterLink`
`EncounterLink` stores a table asset + stable `internalId` GUID. Rename-safe (the GUID doesn't change) and diffable. The custom drawer renders two dropdowns: first pick a table, then pick an encounter inside it.
### Gated quest progression
`QuestProgress` walks an `EncountersCollection` at construction and builds a map of "predecessor quest encounter" for every target of a `QuestKind.nextEncounter`. An encounter is gated iff its predecessor hasn't been resolved:
```csharp
var questProgress = new QuestProgress(encountersCollection);
// When rolling from a table, exclude gated entries:
var next = table.GetRandomEncounter(e => !questProgress.IsGated(e));
// After showing any encounter to the party:
questProgress.OnEncounterTriggered(shown);
```
Progression fires `QuestStarted` / `QuestAdvanced(from, to)` / `QuestCompleted` events. Single-step quests (root with no `nextEncounter`) fire both `QuestStarted` and `QuestCompleted`.
### Quest log (serialisable record)
`QuestLog` subscribes to the three `QuestProgress` events and appends a `QuestLogEntry` for each. `CreateSnapshot()` returns the save payload; `Restore(saved)` rehydrates. Pair it with `QuestProgress.LoadResolvedIds(questLog.ResolvedEncounterIds())` on load to restore the gating set.
## Runtime API Cheatsheet
```csharp
// Encounter rolling with gating
IEncounter next = table.GetRandomEncounter(e => !questProgress.IsGated(e));
// Resolving a picked dialog option
resolver.Resolve(chosenOption.events, new EncounterContext(currentEncounter));
// After any encounter fires
questProgress.OnEncounterTriggered(shown);
// Save/load quest state
save.questLogEntries = questLog.CreateSnapshot();
questLog.Restore(save.questLogEntries);
questProgress.LoadResolvedIds(questLog.ResolvedEncounterIds());
// Fast id → encounter lookup
encounterRegistry.GetEncounters().TryGetValue(id, out var encounter);
```
## Menu Items
| Menu Path | Description |
|-----------|-------------|
| Assets → Create → Jovian → Encounter System → Encounter Table | New table asset |
| Assets → Create → Jovian → Encounter System → Encounters Collection | New collection asset |
| Assets → Create → Jovian → Encounter System → Dialog Option Set | New dialog option set (auto-renames to id) |
| Assets → Create → Jovian → Encounter System → Dialog Line Library | New shared dialog line library |
| Assets → Create → Jovian → Encounter System → Encounter Registry | New registry asset |
| Jovian → Encounters → Encounter Browser | Searchable designer browser |
| Jovian → Encounters → Validate All | Scan all tables/rewards for issues, print click-through report |
## Composition Pattern
Per the project's composition-over-inheritance stance, extending behaviour means:
- **New encounter type?** Add a new `IEncounterKind` implementation.
- **New dialog side effect?** Add a new `IEncounterEvent` + `resolver.Register<T>`.
- **Never** subclass `Encounter` or stack `IEncounter` inheritance chains.
Inheritance is only used where Unity effectively requires it — ScriptableObject assets (`EncounterTable`, `EncountersCollection`, `EncounterDialogOptionSet`, `EncounterRegistry`) and the `EncounterReference` MonoBehaviour scaffold.
## Known Fragilities
- `[SerializeReference]` stores the concrete type's full name + assembly in the YAML. Renaming or moving an `IEncounterKind` or `IEncounterEvent` class drops existing instances silently. Use `[MovedFrom]` when refactoring:
```csharp
[MovedFrom("Jovian.EncounterSystem", "Jovian.EncounterSystem", "StartCombatEvent")]
public class BeginCombatEvent : IEncounterEvent { ... }
```
- `EncounterDefinition.internalId` is a GUID created at class construction. If you duplicate an encounter by copying YAML, dedupe the id before saving — otherwise `EncounterLink` lookups become ambiguous.

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
[Serializable]
public class DialogLine {
public string id;
[TextArea(2, 6)] public string text;
}
/// <summary>Flat registry of reusable dialog lines. Referenced via <see cref="DialogLineRef"/>.</summary>
[CreateAssetMenu(fileName = "DialogLineLibrary", menuName = "Jovian/Encounter System/Dialog Line Library", order = 4)]
public class DialogLineLibrary : ScriptableObject {
public List<DialogLine> lines = new();
private Dictionary<string, string> cache;
public string Resolve(string id) {
if(string.IsNullOrEmpty(id)) {
return null;
}
EnsureCache();
return cache.TryGetValue(id, out var text) ? text : null;
}
public void InvalidateCache() {
cache = null;
}
private void EnsureCache() {
if(cache != null) {
return;
}
cache = new Dictionary<string, string>();
if(lines == null) {
return;
}
foreach(var line in lines) {
if(line != null && !string.IsNullOrEmpty(line.id)) {
cache[line.id] = line.text;
}
}
}
#if UNITY_EDITOR
private void OnValidate() {
InvalidateCache();
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,21 @@
using System;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Looks up <see cref="id"/> in the library passed to <see cref="Resolve"/>; falls back to <see cref="inlineText"/>.</summary>
[Serializable]
public struct DialogLineRef {
public string id;
[TextArea(2, 6)] public string inlineText;
public string Resolve(DialogLineLibrary library) {
if(library != null && !string.IsNullOrEmpty(id)) {
var text = library.Resolve(id);
if(!string.IsNullOrEmpty(text)) {
return text;
}
}
return inlineText;
}
}
}

View File

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

View File

@@ -0,0 +1,10 @@
namespace Jovian.EncounterSystem {
/// <summary>Per-resolution scratch object passed to every event handler. Extend with fields as handlers need them.</summary>
public class EncounterContext {
public IEncounter CurrentEncounter { get; }
public EncounterContext(IEncounter currentEncounter) {
CurrentEncounter = currentEncounter;
}
}
}

View File

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

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Reusable dialog option list. Asset file auto-renames to match <see cref="id"/>.</summary>
[CreateAssetMenu(fileName = "EncounterDialogOptionSet", menuName = "Jovian/Encounter System/Dialog Option Set", order = 2)]
public class EncounterDialogOptionSet : ScriptableObject {
public string id;
/// <summary>Shared library for every option's <see cref="EncounterDialogOption.text"/> lookup.</summary>
public DialogLineLibrary library;
public List<EncounterDialogOption> options;
#if UNITY_EDITOR
private void OnValidate() {
if(string.IsNullOrEmpty(id)) {
return;
}
// AssetDatabase calls are unsafe from OnValidate — defer.
UnityEditor.EditorApplication.delayCall += RenameToMatchId;
}
private void RenameToMatchId() {
if(this == null || string.IsNullOrEmpty(id)) {
return;
}
var path = UnityEditor.AssetDatabase.GetAssetPath(this);
if(string.IsNullOrEmpty(path) || name == id) {
return;
}
var error = UnityEditor.AssetDatabase.RenameAsset(path, id);
if(!string.IsNullOrEmpty(error)) {
Debug.LogWarning($"[EncounterDialogOptionSet] Could not rename '{path}' to '{id}': {error}", this);
}
}
#endif
}
}

View File

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

View File

@@ -0,0 +1,24 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Cross-table reference. Rename-safe — the stored key is a GUID.</summary>
[Serializable]
public struct EncounterLink {
public EncounterTable table;
public string internalId;
public IEncounter Resolve() {
if(table == null || table.encounters == null || string.IsNullOrEmpty(internalId)) {
return null;
}
foreach(var encounter in table.encounters) {
if(encounter?.EncounterDefinition?.internalId == internalId) {
return encounter;
}
}
return null;
}
}
}

View File

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

View File

@@ -0,0 +1,14 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Jovian.EncounterSystem {
/// <summary>Shared view scaffold — game code binds an <see cref="IEncounter"/> to these widgets.</summary>
public class EncounterReference : MonoBehaviour {
public TextMeshProUGUI encounterName;
public TextMeshProUGUI encounterDescription;
public Image encounterArt;
public Transform encounterOptionsContainer;
public Button submitButton;
}
}

View File

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

View File

@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
namespace Jovian.EncounterSystem {
/// <summary>id → encounter cache. Editor auto-repopulates on asset changes; runtime must call <see cref="PopulateEncounters"/>.</summary>
[CreateAssetMenu(fileName = "EncounterRegistry", menuName = "Jovian/Encounter System/Encounter Registry")]
public class EncounterRegistry : ScriptableObject {
public EncountersCollection[] encounterCollections = Array.Empty<EncountersCollection>();
private readonly Dictionary<string, IEncounter> encounters = new();
public Dictionary<string, IEncounter> GetEncounters() => encounters;
public void RegisterEncounter(IEncounter encounter) {
encounters?.TryAdd(encounter?.EncounterDefinition?.internalId, encounter);
}
public void UnregisterEncounter(IEncounter encounter) {
encounters.Remove(encounter.EncounterDefinition.internalId);
}
public void ClearEncounters() {
encounters.Clear();
}
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>Rebuilds the registry (Addressables key "EncounterRegistry") on any asset import/move/delete.</summary>
public class EncounterRegister : UnityEditor.AssetPostprocessor {
private static EncounterRegistry registryCache;
private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) {
registryCache ??= Addressables.LoadAssetAsync<EncounterRegistry>("EncounterRegistry").WaitForCompletion();
registryCache.ClearEncounters();
registryCache.PopulateEncounters();
}
}
#endif
}

View File

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

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
/// <summary>Dispatches <see cref="IEncounterEvent"/> instances to per-type handlers. Unknown types are skipped.</summary>
public class EncounterResolver {
private readonly Dictionary<Type, Action<IEncounterEvent, EncounterContext>> handlers = new();
public void Register<T>(Action<T, EncounterContext> handler) where T : IEncounterEvent {
// Wrap the typed delegate so the dictionary can hold handlers for any event type uniformly.
// Cast is safe because the wrapper is only invoked via lookup under typeof(T).
handlers[typeof(T)] = (evt, ctx) => handler((T)evt, ctx);
}
public void Unregister<T>() where T : IEncounterEvent {
handlers.Remove(typeof(T));
}
public void Resolve(IEnumerable<IEncounterEvent> events, EncounterContext context) {
if(events == null) {
return;
}
foreach(var evt in events) {
if(evt == null) {
continue;
}
if(handlers.TryGetValue(evt.GetType(), out var handler)) {
handler(evt, context);
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
[CreateAssetMenu(fileName = "EncounterTable", menuName = "Jovian/Encounter System/Encounter Table", order = 1)]
public class EncounterTable : ScriptableObject {
public string id;
public List<Encounter> encounters;
public IEncounter GetRandomEncounter() {
if(encounters == null || encounters.Count == 0) {
return null;
}
return encounters[UnityEngine.Random.Range(0, encounters.Count)];
}
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>Random pick limited by a predicate. Used with <see cref="QuestProgress.IsGated"/> to exclude gated encounters.</summary>
public IEncounter GetRandomEncounter(Predicate<IEncounter> filter) {
if(encounters == null || encounters.Count == 0 || filter == null) {
return GetRandomEncounter();
}
var pool = encounters.FindAll(filter);
if(pool.Count == 0) {
return null;
}
return pool[UnityEngine.Random.Range(0, pool.Count)];
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
using UnityEngine;
namespace Jovian.EncounterSystem {
[CreateAssetMenu(fileName = "EncountersCollection", menuName = "Jovian/Encounter System/Encounters Collection", order = 0)]
public class EncountersCollection : ScriptableObject {
public EncounterTable[] encounterTables;
}
}

View File

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

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
public interface IEncounter {
EncounterDefinition EncounterDefinition { get; set; }
EncounterProperties EncounterProperties { get; set; }
EncounterVisuals EncounterVisuals { get; set; }
EncounterDialogOptionSet EncounterDialogOptionSet { get; set; }
IEncounterKind Kind { get; set; }
}
/// <summary>Default concrete encounter. Extend via a new <see cref="IEncounterKind"/>, not by subclassing.</summary>
[Serializable]
public class Encounter : IEncounter {
[field: SerializeField] public EncounterDefinition EncounterDefinition { get; set; }
[field: SerializeField] public EncounterProperties EncounterProperties { get; set; }
[field: SerializeField] public EncounterVisuals EncounterVisuals { get; set; }
[field: SerializeField] public EncounterDialogOptionSet EncounterDialogOptionSet { get; set; }
[field: SerializeReference, SubclassSelector]
public IEncounterKind Kind { get; set; }
}
[Serializable]
public class EncounterDefinition {
/// <summary>Stable GUID assigned at creation. Never edit manually.</summary>
[HideInInspector]
public string internalId = Guid.NewGuid().ToString();
public string id;
public string name;
public string description;
}
[Serializable]
public class EncounterDialogOption {
public DialogLineRef text;
[SerializeReference, SubclassSelector]
public List<IEncounterEvent> events;
}
[Serializable]
public class EncounterVisuals {
public Sprite icon;
public Color encounterColor;
public Sprite encounterArt;
}
[Serializable]
public class EncounterProperties {
public int difficulty;
}
}

View File

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

View File

@@ -0,0 +1,27 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Data-only dialog option side effect. Handlers are registered on <see cref="EncounterResolver"/>.</summary>
public interface IEncounterEvent {
}
[Serializable]
public class ChainToEncounterEvent : IEncounterEvent {
public string nextEncounterId;
}
[Serializable]
public class StartCombatEvent : IEncounterEvent {
public string combatEncounterId;
}
[Serializable]
public class LogEvent : IEncounterEvent {
public string message;
}
[Serializable]
public class GiveRewardEvent : IEncounterEvent {
public Reward reward;
}
}

View File

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

View File

@@ -0,0 +1,52 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Polymorphic payload on an <see cref="IEncounter"/>. Add a new kind by implementing
/// this interface; the SubclassSelector drawer surfaces it automatically.</summary>
public interface IEncounterKind {
}
[Serializable]
public class CombatKind : IEncounterKind {
public string enemyGroupId;
public string rewardTableId;
}
[Serializable]
public class QuestKind : IEncounterKind {
public EncounterLink nextEncounter;
public string questTitle;
}
[Serializable]
public class SocialKind : IEncounterKind {
public string npcId;
public string factionId;
public int reputationDelta;
}
[Serializable]
public class PuzzleKind : IEncounterKind {
public string puzzleId;
public int difficultyClass;
}
[Serializable]
public class ExplorationKind : IEncounterKind {
public int perceptionDC;
}
[Serializable]
public class TutorialKind : IEncounterKind {
public string tutorialId;
}
[Serializable]
public class HazardKind : IEncounterKind {
public int damageAmount;
}
[Serializable]
public class OtherKind : IEncounterKind {
}
}

View File

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

View File

@@ -0,0 +1,33 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Polymorphic payload on a <see cref="Reward"/>. Add a new kind by implementing this interface.</summary>
public interface IRewardKind {
}
[Serializable]
public class CurrencyRewardKind : IRewardKind {
public string currencyId;
public int amount;
}
[Serializable]
public class ItemRewardKind : IRewardKind {
public string itemId;
public int quantity;
}
[Serializable]
public class ExperienceRewardKind : IRewardKind {
public int amount;
}
[Serializable]
public class UnlockableRewardKind : IRewardKind {
public string unlockableId;
}
[Serializable]
public class OtherRewardKind : IRewardKind {
}
}

View File

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

View File

@@ -0,0 +1,19 @@
{
"name": "Jovian.EncounterSystem",
"rootNamespace": "Jovian.EncounterSystem",
"references": [
"UnityEngine.UI",
"Unity.TextMeshPro",
"Unity.Addressables",
"Unity.ResourceManager"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

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

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Jovian.EncounterSystem {
public enum QuestLogEventType {
Started,
Advanced,
Completed
}
[Serializable]
public class QuestLogEntry {
public QuestLogEventType type;
public string encounterInternalId;
public string encounterName;
public string fromEncounterName;
}
/// <summary>
/// Chronological, serialisable record of quest events. Subscribes at construction —
/// build before any encounter fires or early entries will be missed.
/// </summary>
public class QuestLog {
private readonly List<QuestLogEntry> entries = new();
public IReadOnlyList<QuestLogEntry> Entries => entries;
public QuestLog(QuestProgress progress) {
progress.QuestStarted += quest => Record(QuestLogEventType.Started, null, quest);
progress.QuestAdvanced += (from, to) => Record(QuestLogEventType.Advanced, from, to);
progress.QuestCompleted += quest => Record(QuestLogEventType.Completed, null, quest);
}
public List<QuestLogEntry> CreateSnapshot() {
return new List<QuestLogEntry>(entries);
}
public void Restore(IEnumerable<QuestLogEntry> saved) {
entries.Clear();
if(saved == null) {
return;
}
entries.AddRange(saved);
}
public IEnumerable<string> ResolvedEncounterIds() {
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
});
}
}
}

View File

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

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
/// <summary>
/// Gated quest progression. Encounter E is gated iff some QuestKind encounter P has
/// <c>P.nextEncounter == E</c> and P hasn't been resolved. Predecessor map is built once at
/// construction; rolling and advancement are O(1).
/// </summary>
public class QuestProgress {
private readonly HashSet<string> resolvedIds = new();
private readonly Dictionary<string, IEncounter> predecessorOf = new();
public event Action<IEncounter> QuestStarted;
public event Action<IEncounter, IEncounter> QuestAdvanced;
public event Action<IEncounter> QuestCompleted;
public IReadOnlyCollection<string> ResolvedIds => resolvedIds;
public QuestProgress(EncountersCollection encountersCollection) {
IndexQuests(encountersCollection);
}
public bool IsGated(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return false;
}
if(!predecessorOf.TryGetValue(id, out var predecessor)) {
return false;
}
return !resolvedIds.Contains(predecessor.EncounterDefinition.internalId);
}
public void OnEncounterTriggered(IEncounter encounter) {
if(encounter?.Kind is not QuestKind questKind) {
return;
}
var id = encounter.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id) || !resolvedIds.Add(id)) {
return;
}
var next = questKind.nextEncounter.Resolve();
var hasPredecessor = predecessorOf.TryGetValue(id, out var predecessor);
if(!hasPredecessor) {
QuestStarted?.Invoke(encounter);
}
else {
QuestAdvanced?.Invoke(predecessor, encounter);
}
if(next == null) {
QuestCompleted?.Invoke(encounter);
}
}
public void LoadResolvedIds(IEnumerable<string> ids) {
resolvedIds.Clear();
if(ids == null) {
return;
}
foreach(var id in ids) {
if(!string.IsNullOrEmpty(id)) {
resolvedIds.Add(id);
}
}
}
private void IndexQuests(EncountersCollection collection) {
if(collection?.encounterTables == null) {
return;
}
foreach(var table in collection.encounterTables) {
if(table?.encounters == null) {
continue;
}
foreach(var encounter in table.encounters) {
if(encounter?.Kind is not QuestKind questKind) {
continue;
}
var next = questKind.nextEncounter.Resolve();
if(next == null) {
continue;
}
var nextId = next.EncounterDefinition?.internalId;
if(!string.IsNullOrEmpty(nextId)) {
predecessorOf[nextId] = encounter;
}
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,13 @@
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Reusable reward asset referenced by <see cref="GiveRewardEvent"/>.</summary>
[CreateAssetMenu(fileName = "Reward", menuName = "Jovian/Encounter System/Reward", order = 3)]
public class Reward : ScriptableObject {
public string id;
public string displayName;
[SerializeReference, SubclassSelector]
public IRewardKind kind;
}
}

View File

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

View File

@@ -0,0 +1,9 @@
using System;
using UnityEngine;
namespace Jovian.EncounterSystem {
/// <summary>Renders a concrete-type picker dropdown for a <c>[SerializeReference]</c> field.</summary>
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class SubclassSelectorAttribute : PropertyAttribute {
}
}

View File

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

View File

@@ -0,0 +1,19 @@
{
"name": "com.jovian.encounter-system",
"version": "0.1.0",
"displayName": "Jovian Encounter System",
"description": "Data-driven encounter authoring with polymorphic encounter kinds, dialog options with designer-authored events, cross-table quest chaining, and gated quest progression.",
"unity": "2022.3",
"dependencies": {
"com.unity.addressables": "1.21.0"
},
"keywords": [
"encounter",
"quest",
"dialog",
"rpg"
],
"author": {
"name": "Jovian"
}
}

View File

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

View File

@@ -52,14 +52,14 @@ namespace Jovian.ZoneSystem.Editor {
} }
} }
[MenuItem("Window/Zone System/Settings")] [MenuItem("Jovian/Zone System/Settings")]
private static void SelectOrCreateSettings() { private static void SelectOrCreateSettings() {
ZoneEditorSettings settings = FindOrCreateSettings(); ZoneEditorSettings settings = FindOrCreateSettings();
Selection.activeObject = settings; Selection.activeObject = settings;
EditorGUIUtility.PingObject(settings); EditorGUIUtility.PingObject(settings);
} }
[MenuItem("Window/Zone System/Documentation")] [MenuItem("Jovian/Zone System/Documentation")]
private static void OpenDocumentation() { private static void OpenDocumentation() {
// Find the Documentation~ folder relative to this package // Find the Documentation~ folder relative to this package
string[] guids = AssetDatabase.FindAssets("t:Script ZoneEditorSettings"); string[] guids = AssetDatabase.FindAssets("t:Script ZoneEditorSettings");

View File

@@ -72,7 +72,7 @@ namespace Jovian.ZoneSystem.Editor {
} }
} }
[MenuItem("Window/Zone System/Zone Editor")] [MenuItem("Jovian/Zone System/Zone Editor")]
public static void Open() { public static void Open() {
GetWindow<ZoneEditorWindow>("Zone Editor"); GetWindow<ZoneEditorWindow>("Zone Editor");
} }

View File

@@ -18,7 +18,7 @@ Packages/com.jovian.zonesystem/
│ ├── ShapeFactory.cs ← Default shape generation (square, circle, polygon) │ ├── ShapeFactory.cs ← Default shape generation (square, circle, polygon)
│ └── ZoneExporter.cs ← Serialization to JSON │ └── ZoneExporter.cs ← Serialization to JSON
├── Editor/ ├── Editor/
│ ├── ZoneEditorWindow.cs ← Main editor window (Window → Zone System → Zone Editor) │ ├── ZoneEditorWindow.cs ← Main editor window (Jovian → Zone System → Zone Editor)
│ ├── ZoneEditorSettings.cs ← Configurable settings: folder path, role colors │ ├── ZoneEditorSettings.cs ← Configurable settings: folder path, role colors
│ ├── ZoneInstanceEditor.cs ← Custom inspector + scene handles for shape editing │ ├── ZoneInstanceEditor.cs ← Custom inspector + scene handles for shape editing
│ └── ZoneDataEditor.cs ← Role-aware ZoneData inspector │ └── ZoneDataEditor.cs ← Role-aware ZoneData inspector
@@ -30,7 +30,7 @@ Packages/com.jovian.zonesystem/
1. Add the package to your project (local package in `Packages/`). 1. Add the package to your project (local package in `Packages/`).
2. Create a **ZonesObjectHolder** GameObject and set **Map Plane** to match your map (e.g. `XZ`). 2. Create a **ZonesObjectHolder** GameObject and set **Map Plane** to match your map (e.g. `XZ`).
3. Open **Window → Zone System → Zone Editor**. 3. Open **Jovian → Zone System → Zone Editor**.
4. Click **Create New Zone**, set a name and shape, then click **Create & Edit**. 4. Click **Create New Zone**, set a name and shape, then click **Create & Edit**.
5. Edit all zone data fields in the editor, then click **Save Zone**. 5. Edit all zone data fields in the editor, then click **Save Zone**.
6. Use scene handles to adjust the polygon shape. 6. Use scene handles to adjust the polygon shape.
@@ -51,9 +51,9 @@ Packages/com.jovian.zonesystem/
| Menu Path | Description | | Menu Path | Description |
|-----------|-------------| |-----------|-------------|
| Window → Zone System → Zone Editor | Main editor window | | Jovian → Zone System → Zone Editor | Main editor window |
| Window → Zone System → Settings | Select or create ZoneEditorSettings asset | | Jovian → Zone System → Settings | Select or create ZoneEditorSettings asset |
| Window → Zone System → Documentation | Open HTML documentation | | Jovian → Zone System → Documentation | Open HTML documentation |
## Runtime API ## Runtime API
@@ -75,4 +75,4 @@ List<ZoneData> zones = api.GetOverlappingZones(partyWorldPosition);
## Documentation ## Documentation
Full documentation is available at `Documentation~/index.html`. Open it via **Window → Zone System → Documentation**. Full documentation is available at `Documentation~/index.html`. Open it via **Jovian → Zone System → Documentation**.

View File

@@ -24,6 +24,14 @@
"source": "embedded", "source": "embedded",
"dependencies": {} "dependencies": {}
}, },
"com.jovian.encounter-system": {
"version": "file:com.jovian.encounter-system",
"depth": 0,
"source": "embedded",
"dependencies": {
"com.unity.addressables": "1.21.0"
}
},
"com.jovian.ingame-logging": { "com.jovian.ingame-logging": {
"version": "file:com.jovian.ingame-logging", "version": "file:com.jovian.ingame-logging",
"depth": 0, "depth": 0,

View File

@@ -1 +1 @@
[{"displayName":"Save System","packagePath":"Packages/com.jovian.savesystem","repoPath":"D:\\repos\\unity-save-system","selected":false},{"displayName":"Zone System","packagePath":"Packages/com.jovian.zonesystem","repoPath":"D:\\repos\\unity-zone-system","selected":false},{"displayName":"Unity Inspector","packagePath":"Packages/com.jovian.inspector-tools","repoPath":"d:\\repos\\unity-inspector\\","selected":false},{"displayName":"Unity Logger","packagePath":"Packages/com.jovian.logger","repoPath":"d:\\repos\\unity-logger\\","selected":false},{"displayName":"Unity Recent Assets","packagePath":"Packages/com.jovian.assets-history","repoPath":"d:\\repos\\unity-recentassets\\","selected":false},{"displayName":"Unity Utilities","packagePath":"Packages/com.jovian.utilities","repoPath":"d:\\repos\\unity-utilities\\","selected":false},{"displayName":"Unity Popup System","packagePath":"Packages/com.jovian.popup-system","repoPath":"d:\\repos\\unity-popup-system\\","selected":false},{"displayName":"Unity Calendar System","packagePath":"Packages/com.jovian.calendar","repoPath":"d:\\repos\\unity-calendar-system\\","selected":false}] [{"displayName":"Save System","packagePath":"Packages/com.jovian.savesystem","repoPath":"D:\\repos\\unity-save-system","selected":false},{"displayName":"Zone System","packagePath":"Packages/com.jovian.zonesystem","repoPath":"D:\\repos\\unity-zone-system","selected":false},{"displayName":"Unity Inspector","packagePath":"Packages/com.jovian.inspector-tools","repoPath":"d:\\repos\\unity-inspector\\","selected":false},{"displayName":"Unity Logger","packagePath":"Packages/com.jovian.logger","repoPath":"d:\\repos\\unity-logger\\","selected":false},{"displayName":"Unity Recent Assets","packagePath":"Packages/com.jovian.assets-history","repoPath":"d:\\repos\\unity-recentassets\\","selected":false},{"displayName":"Unity Utilities","packagePath":"Packages/com.jovian.utilities","repoPath":"d:\\repos\\unity-utilities\\","selected":false},{"displayName":"Unity Popup System","packagePath":"Packages/com.jovian.popup-system","repoPath":"d:\\repos\\unity-popup-system\\","selected":false},{"displayName":"Unity Calendar System","packagePath":"Packages/com.jovian.calendar","repoPath":"d:\\repos\\unity-calendar-system\\","selected":false},{"displayName":"Unity Encounter System","packagePath":"Packages/com.jovian.encounter-system","repoPath":"d:\\repos\\encounter-system\\","selected":false}]