First commit

This commit is contained in:
sbularca
2026-05-19 15:52:04 +02:00
commit 27b7aeee46
1660 changed files with 240354 additions and 0 deletions

View File

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

View File

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

View File

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

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 || 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,19 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Cross-table reference. Rename-safe — the stored key is a GUID.</summary>
[Serializable]
public struct EncounterLink {
public EncounterTable table;
public string internalId;
/// <summary>O(1) lookup via <see cref="EncounterTable.Resolve"/>. Returns <c>null</c> if the
/// table is missing or the id can't be found.</summary>
public IEncounter Resolve() {
if(table == null || string.IsNullOrEmpty(internalId)) {
return null;
}
return table.Resolve(internalId);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
namespace Jovian.EncounterSystem {
/// <summary>id → encounter cache. Editor auto-repopulates on asset changes; runtime must call <see cref="PopulateEncounters"/>.</summary>
[CreateAssetMenu(fileName = "EncounterRegistry", menuName = "Jovian/Encounter System/Encounter Registry")]
public class EncounterRegistry : ScriptableObject {
public EncountersCollection[] encounterCollections = Array.Empty<EncountersCollection>();
private readonly Dictionary<string, IEncounter> encounters = new();
public Dictionary<string, IEncounter> GetEncounters() => encounters;
public void RegisterEncounter(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return;
}
encounters.TryAdd(id, encounter);
}
public void UnregisterEncounter(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return;
}
encounters.Remove(id);
}
public void ClearEncounters() {
encounters.Clear();
}
public void PopulateEncounters() {
if(encounterCollections == null) {
return;
}
for(int c = 0; c < encounterCollections.Length; c++) {
var collection = encounterCollections[c];
if(collection?.encounterTables == null) {
continue;
}
for(int t = 0; t < collection.encounterTables.Length; t++) {
var table = collection.encounterTables[t];
if(table?.encounters == null) {
continue;
}
for(int i = 0; i < table.encounters.Count; i++) {
RegisterEncounter(table.encounters[i]);
}
}
}
}
public EncounterTable GetEncounterTable(string id) {
foreach(var collection in encounterCollections) {
foreach(var table in collection.encounterTables) {
if(table.id == id) {
return table;
}
}
}
return null;
}
public IEncounter GetRandomEncounter(string encounterTableId) {
var table = GetEncounterTable(encounterTableId);
if(!table) {
return null;
}
var ec = table.encounters;
return ec[UnityEngine.Random.Range(0, ec.Count)];
}
/// <summary>Random pick restricted to encounters whose runtime type matches <paramref name="type"/>.
/// Zero-alloc — uses reservoir sampling instead of building an intermediate filtered list.</summary>
public IEncounter GetRandomEncounter(string encounterTableId, IEncounterKind encounterKind) {
var table = GetEncounterTable(encounterTableId);
if(!table || encounterKind == null) {
return null;
}
IEncounter selected = null;
var seen = 0;
for(var i = 0; i < encounters.Count; i++) {
var encounter = table.encounters[i];
if(encounter == null || encounter.GetType() != encounterKind.GetType()) {
continue;
}
seen++;
if(UnityEngine.Random.Range(0, seen) == 0) {
selected = encounter;
}
}
return selected;
}
/// <summary>Random pick restricted by <paramref name="filter"/>. Used with
/// <see cref="QuestProgress.IsGated"/> to exclude gated encounters. Zero-alloc via reservoir sampling.</summary>
public IEncounter GetRandomEncounter(Predicate<IEncounter> filter, string encounterTableId) {
var table = GetEncounterTable(encounterTableId);
if(!table) {
return null;
}
if(filter == null) {
return GetRandomEncounter(encounterTableId);
}
IEncounter selected = null;
var seen = 0;
foreach(var encounter in table.encounters) {
if(encounter == null || !filter(encounter)) {
continue;
}
seen++;
if(UnityEngine.Random.Range(0, seen) == 0) {
selected = encounter;
}
}
return selected;
}
}
#if UNITY_EDITOR
/// <summary>Rebuilds the registry (Addressables key "EncounterRegistry") on any asset import/move/delete.</summary>
public class EncounterRegister : UnityEditor.AssetPostprocessor {
private static EncounterRegistry registryCache;
private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) {
registryCache ??= Addressables.LoadAssetAsync<EncounterRegistry>("EncounterRegistry").WaitForCompletion();
registryCache.ClearEncounters();
registryCache.PopulateEncounters();
}
}
#endif
}

View File

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

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
namespace Jovian.EncounterSystem {
/// <summary>Dispatches <see cref="IEncounterEvent"/> instances to per-type handlers. Unknown types are skipped.</summary>
public class EncounterResolver {
private readonly Dictionary<Type, Action<IEncounterEvent, EncounterContext>> handlers = new();
public void Register<T>(Action<T, EncounterContext> handler) where T : IEncounterEvent {
// Wrap the typed delegate so the dictionary can hold handlers for any event type uniformly.
// Cast is safe because the wrapper is only invoked via lookup under typeof(T).
handlers[typeof(T)] = (evt, ctx) => handler((T)evt, ctx);
}
public void Unregister<T>() where T : IEncounterEvent {
handlers.Remove(typeof(T));
}
/// <summary>Indexed iteration over <paramref name="events"/> — avoids the boxed enumerator
/// that an <c>IEnumerable&lt;T&gt;</c> parameter would force. <see cref="EncounterDialogOption.events"/>
/// is a <c>List</c>, which implements <c>IReadOnlyList</c>, so call sites just pass it directly.</summary>
public void Resolve(IReadOnlyList<IEncounterEvent> events, EncounterContext context) {
if(events == null) {
return;
}
var count = events.Count;
for(int i = 0; i < count; i++) {
var evt = events[i];
if(evt == null) {
continue;
}
if(handlers.TryGetValue(evt.GetType(), out var handler)) {
handler(evt, context);
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.EncounterSystem {
[CreateAssetMenu(fileName = "EncounterTable", menuName = "Jovian/Encounter System/Encounter Table", order = 1)]
public class EncounterTable : ScriptableObject {
public string id;
public List<Encounter> encounters;
private Dictionary<string, Encounter> idCache;
/// <summary>O(1) lookup by <see cref="EncounterDefinition.internalId"/>. Used by <see cref="EncounterLink"/>.
/// Call <see cref="InvalidateCache"/> if you mutate the <see cref="encounters"/> list at runtime.</summary>
public IEncounter Resolve(string internalId) {
if(string.IsNullOrEmpty(internalId) || encounters == null || encounters.Count == 0) {
return null;
}
EnsureCache();
return idCache.GetValueOrDefault(internalId);
}
/// <summary>Force the id lookup cache to rebuild on next use.</summary>
public void InvalidateCache() {
idCache = null;
}
private void EnsureCache() {
if(idCache != null) {
return;
}
idCache = new Dictionary<string, Encounter>(encounters?.Count ?? 0);
if(encounters == null) {
return;
}
for(var i = 0; i < encounters.Count; i++) {
var encounter = encounters[i];
var internalId = encounter?.EncounterDefinition?.internalId;
if(!string.IsNullOrEmpty(internalId)) {
idCache[internalId] = encounter;
}
}
}
#if UNITY_EDITOR
// Unity's inspector "+" duplicates the previous list element, including nested internalId
// GUIDs. Regenerate any duplicates so every encounter carries a unique internalId.
private void OnValidate() {
InvalidateCache();
if(encounters == null) {
return;
}
var seen = new HashSet<string>(encounters.Count);
var changed = false;
for(int i = 0; i < encounters.Count; i++) {
var encounter = encounters[i];
if(encounter?.EncounterDefinition == null) {
continue;
}
var entryId = encounter.EncounterDefinition.internalId;
if(string.IsNullOrEmpty(entryId) || !seen.Add(entryId)) {
encounter.EncounterDefinition.internalId = Guid.NewGuid().ToString();
seen.Add(encounter.EncounterDefinition.internalId);
changed = true;
}
}
if(changed) {
UnityEditor.EditorUtility.SetDirty(this);
}
}
#endif
}
}

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

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,12 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Polymorphic payload on an <see cref="IEncounter"/>. Add a new kind by implementing
/// this interface; the SubclassSelector drawer surfaces it automatically.</summary>
public interface IEncounterKind { }
[Serializable]
public class QuestKind : IEncounterKind {
public EncounterLink nextEncounter;
}
}

View File

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

View File

@@ -0,0 +1,7 @@
using System;
namespace Jovian.EncounterSystem {
/// <summary>Polymorphic payload on a <see cref="Reward"/>. Add a new kind by implementing this interface.</summary>
public interface IRewardKind {
}
}

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

View File

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

View File

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

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