2026-04-19 12:27:13 +02:00
2026-04-19 12:27:13 +02:00
2026-04-19 12:25:49 +02:00
2026-04-19 12:25:49 +02:00
2026-04-19 12:25:49 +02:00
2026-04-19 12:25:49 +02:00
2026-04-19 12:25:49 +02:00
2026-04-19 12:25:49 +02:00
2026-04-19 12:27:13 +02:00
2026-04-19 12:25:49 +02:00
2026-04-19 12:25:49 +02:00

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:

[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:

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:

[Serializable]
public class PotionOfHealingRewardKind : IRewardKind {
    public int healAmount = 50;
}

To grant rewards, drop GiveRewardEvents 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:

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.

resolver.Register<SomeEvent>((evt, ctx) => {
    var text = option.text.Resolve();  // library lookup, inline fallback, null if both empty
});

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:

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

// 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:
    [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.
Description
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.
Readme MIT 134 KiB
Languages
C# 100%