diff --git a/Assets/Code/GameState/NoxSaveData.cs b/Assets/Code/GameState/NoxSaveData.cs index 20889e0..534f025 100644 --- a/Assets/Code/GameState/NoxSaveData.cs +++ b/Assets/Code/GameState/NoxSaveData.cs @@ -1,7 +1,10 @@ using Jovian.SaveSystem; using Jovian.InGameLogging; +using Jovian.EncounterSystem; using Nox.Core; using System; +using System.Collections.Generic; +using System.Linq; using ZLinq; using UnityEngine; using PlayMode = Nox.Core.PlayMode; @@ -36,14 +39,15 @@ namespace Nox.Game { gameDataState.activeSessionId = latestSession.sessionId; gameDataState.savedPartyPosition = saveData.partyPosition.ToVector3(); gameDataState.ActiveParty = saveData.activeParty; - adventureData.isPartyMoving = saveData.adventureData.isPartyMoving; + adventureData.isPartyMoving = saveData.adventureData.isPartyMoving; adventureData.isEncounterActive = saveData.adventureData.isEncounterActive; adventureData.currentDay = saveData.adventureData.currentDay; adventureData.suppliesAvailable = saveData.adventureData.suppliesAvailable; adventureData.currentTime = saveData.adventureData.currentTime; adventureData.currentDayPhase = saveData.adventureData.currentDayPhase; - adventureData.completedEncounterIds.Clear(); adventureData.completedEncounterIds.AddRange(saveData.adventureData.completedEncounterIds); + adventureData.abandonedEncounterIds.AddRange(saveData.adventureData.abandonedEncounterIds ?? Enumerable.Empty()); + adventureData.questLogEntries.AddRange(saveData.questLogEntries ?? Enumerable.Empty()); if(gameLogStore != null && saveData.gameLogData != null) { gameLogStore.RestoreFromSaveData(saveData.gameLogData); @@ -65,6 +69,9 @@ namespace Nox.Game { //game mode specific data public AdventureData adventureData; + // Quest progression + public List questLogEntries; + // Party public PartyDefinition activeParty; public SerializableVector3 partyPosition; diff --git a/Assets/Code/GameState/PlayModes/AdventurePlayMode.cs b/Assets/Code/GameState/PlayModes/AdventurePlayMode.cs index b680dc6..5512628 100644 --- a/Assets/Code/GameState/PlayModes/AdventurePlayMode.cs +++ b/Assets/Code/GameState/PlayModes/AdventurePlayMode.cs @@ -10,6 +10,7 @@ using Nox.Platform; using Nox.Game.UI; using Nox.UI; using System; +using System.Linq; using UnityEngine; using UnityEngine.AddressableAssets; using ZLinq; @@ -25,7 +26,9 @@ namespace Nox.Game { public int suppliesAvailable = -1; public float currentTime = -1f; public DayPhase currentDayPhase = DayPhase.Morning; - public List completedEncounterIds = new List(); + public List completedEncounterIds = new (); + public List abandonedEncounterIds = new (); + public List questLogEntries = new (); } public class AdventurePlayMode : IPlayMode { @@ -85,6 +88,9 @@ namespace Nox.Game { .WaitForCompletion().ActivateAsync().completed += InitializeGameMode; } + private QuestProgress questProgress; + private QuestLog questLog; + private void InitializeGameMode(AsyncOperation obj) { inputActions.Player.Enable(); inputActions.UI.PauseMenu.Enable(); @@ -118,18 +124,29 @@ namespace Nox.Game { adventureData.currentTime = 0.25f; } + var collection = encounterRegistry.encounterCollections?.FirstOrDefault(c => c?.encounterTables != null); + if(collection != null) { + questProgress = new QuestProgress(collection); + questLog = new QuestLog(questProgress); + if(adventureData.questLogEntries.Count > 0) { + questLog.Restore(adventureData.questLogEntries); + questProgress.LoadResolvedIds(questLog.ResolvedEncounterIds()); + } + } + partyInventoryHandler ??= new PartyInventoryHandler(adventureData, adventureSettings); partyInventoryHandler.Initialize(); - var calendarSettings = Addressables.LoadAssetAsync("CalendarSettings").WaitForCompletion(); + var calendarSettings = Addressables.LoadAssetAsync("CalendarSettings").WaitForCompletion(); var worldClock = new WorldClock(calendarSettings); var totalMinutes = adventureData.currentDay * calendarSettings.MinutesPerDay + (int)(adventureData.currentTime * calendarSettings.MinutesPerDay); worldClock.AdvanceMinutes(totalMinutes); + worldClock.Tick(adventureData.currentTime); timeHandler ??= new TimeHandler(adventureSettings, adventureData, worldClock); zoneSystem ??= new ZoneSystem(mapRef.zonesObjectHolder); - encounterHandler = new EncounterHandler(zoneSystem, encounterRegistry, encounterPrefabs, adventureData); + encounterHandler = new EncounterHandler(zoneSystem, encounterRegistry, encounterPrefabs, adventureData, questProgress); partyMovementHandler ??= new PartyMovementHandler( partyRef, cameraController, @@ -190,7 +207,8 @@ namespace Nox.Game { activePlayMode = PlayMode.Adventure, activeParty = partyDefinition, partyPosition = partyRef ? SerializableVector3.FromVector3(partyRef.transform.position) : SerializableVector3.Zero, - adventureData = this.adventureData + adventureData = this.adventureData, + questLogEntries = questLog?.CreateSnapshot() ?? new List() }; } diff --git a/Assets/Code/GameState/PlayModes/Encounters/EncounterHandler.cs b/Assets/Code/GameState/PlayModes/Encounters/EncounterHandler.cs index 818e1ca..d75cee7 100644 --- a/Assets/Code/GameState/PlayModes/Encounters/EncounterHandler.cs +++ b/Assets/Code/GameState/PlayModes/Encounters/EncounterHandler.cs @@ -9,20 +9,64 @@ namespace Nox.Game { private readonly EncounterRegistry encounterRegistry; private readonly EncounterView encounterView; private readonly AdventureData adventureData; + private readonly QuestProgress questProgress; + private readonly EncounterResolver resolver; private Vector3 lastKnownPosition; private string previousZoneId; private IEncounter activeEncounter; private int lastCheckedDay; + private float nextEncounterRollTime; + private bool encounterRolledToday; - public EncounterHandler(ZoneSystem zoneSystem, EncounterRegistry encounterRegistry, EncounterPrefabs encounterPrefabs, AdventureData adventureData) { + public EncounterHandler(ZoneSystem zoneSystem, EncounterRegistry encounterRegistry, EncounterPrefabs encounterPrefabs, AdventureData adventureData, QuestProgress questProgress) { this.zoneSystem = zoneSystem; this.encounterRegistry = encounterRegistry; this.adventureData = adventureData; + this.questProgress = questProgress; + this.resolver = new EncounterResolver(); + RegisterHandlers(); encounterView = new EncounterView(encounterPrefabs); encounterView.OptionSelected += OnOptionSelected; lastCheckedDay = adventureData.currentDay; } + private void RegisterHandlers() { + resolver.Register(HandleChain); + resolver.Register(HandleLog); + resolver.Register(HandleAbandon); + resolver.Register(HandleCombat); + resolver.Register(HandleReward); + } + + private bool HandleChain(ChainToEncounterEvent evt, EncounterContext ctx) { + if(encounterRegistry.GetEncounters().TryGetValue(evt.nextEncounterId, out var next)) { + RecordEncounterCompleted(activeEncounter); + TriggerEncounter(next); + return true; + } + return false; + } + + private bool HandleLog(LogEvent evt, EncounterContext ctx) { + Debug.Log($"[Encounter '{ctx.CurrentEncounter?.EncounterDefinition?.id}'] {evt.message}"); + return false; + } + + private bool HandleAbandon(AbandonEncounterEvent evt, EncounterContext ctx) { + FinishEncounter(abandoned: true); + return true; + } + + private bool HandleCombat(StartCombatEvent evt, EncounterContext ctx) { + Debug.Log($"[Encounter] unhandled event {evt.GetType().Name}"); + return false; + } + + private bool HandleReward(GiveRewardEvent evt, EncounterContext ctx) { + Debug.Log($"[Encounter] unhandled event {evt.GetType().Name}"); + return false; + } + public bool AskForRandomEncounter(ZoneContext zoneContext, string encounterTableId, out IEncounter encounter) { return ResolveEncounter(zoneContext, encounterTableId, out encounter); } @@ -49,7 +93,7 @@ namespace Nox.Game { return false; } - var filter = new Predicate(e => e != null && !IsAlreadyCompleted(e)); + var filter = new Predicate(e => e != null && !IsAlreadyCompleted(e) && !IsGated(e)); if(encounterKind == null) { encounter = encounterRegistry.GetRandomEncounter(filter, encounterTableId); @@ -88,9 +132,18 @@ namespace Nox.Game { return false; } + // abandoned non-repeatable encounters are re-triggerable + if(adventureData.abandonedEncounterIds.Contains(def.id)) { + return false; + } + return adventureData.completedEncounterIds.Contains(def.id); } + private bool IsGated(IEncounter encounter) { + return questProgress != null && questProgress.IsGated(encounter); + } + private void RecordEncounterCompleted(IEncounter encounter) { var def = encounter?.EncounterDefinition; if(def == null || string.IsNullOrEmpty(def.id)) { @@ -108,6 +161,19 @@ namespace Nox.Game { } } + private void RecordEncounterAbandoned(IEncounter encounter) { + var def = encounter?.EncounterDefinition; + if(def == null || string.IsNullOrEmpty(def.id)) { + return; + } + + if(!adventureData.abandonedEncounterIds.Contains(def.id)) { + adventureData.abandonedEncounterIds.Add(def.id); + Debug.Log($"Recorded abandoned encounter: {def.id}"); + questProgress?.OnEncounterAbandoned(encounter); + } + } + private void TrackZoneTransition(Vector3 position) { var zoneContext = zoneSystem.QueryZone(position); var currentZoneId = zoneContext.resolvedZoneId; @@ -115,6 +181,9 @@ namespace Nox.Game { if(currentZoneId != previousZoneId) { if(!string.IsNullOrEmpty(currentZoneId)) { Debug.Log($"Entered zone: {currentZoneId} (encounter: {zoneContext.encounterTableId}, safe: {zoneContext.isSafe})"); + if(!zoneContext.isSafe) { + RollForEncounter(zoneContext); + } } else if(!string.IsNullOrEmpty(previousZoneId)) { Debug.Log($"Left zone: {previousZoneId}"); @@ -123,8 +192,7 @@ namespace Nox.Game { } } - private void DailyEncounterCheck(Vector3 position) { - var zoneContext = zoneSystem.QueryZone(position); + private void RollForEncounter(ZoneContext zoneContext) { if(string.IsNullOrEmpty(zoneContext.resolvedZoneId) || zoneContext.isSafe) { return; } @@ -140,14 +208,20 @@ namespace Nox.Game { default: activeEncounter = encounter; adventureData.isEncounterActive = true; + questProgress?.OnEncounterTriggered(encounter); encounterView?.SetCurrentEncounter(encounter); encounterView?.Show(); break; } } - private void FinishEncounter() { - RecordEncounterCompleted(activeEncounter); + private void FinishEncounter(bool abandoned = false) { + if(abandoned) { + RecordEncounterAbandoned(activeEncounter); + } + else { + RecordEncounterCompleted(activeEncounter); + } encounterView?.Hide(); activeEncounter = null; adventureData.isEncounterActive = false; @@ -173,30 +247,14 @@ namespace Nox.Game { return; } - for(var i = 0; i < option.events.Count; i++) { - var encounterEvent = option.events[i]; - if(encounterEvent == null) { - continue; - } - switch(encounterEvent) { - case ChainToEncounterEvent chain: - if(AskForEncounter(chain.nextEncounterId, out var next)) { - RecordEncounterCompleted(activeEncounter); - TriggerEncounter(next); - return; - } - break; - case LogEvent log: - Debug.Log($"[Encounter '{encounter.EncounterDefinition.id}'] {log.message}"); - break; - case StartCombatEvent _: - case GiveRewardEvent _: - Debug.Log($"[Encounter] unhandled event {encounterEvent.GetType().Name}"); - break; - } - } + var context = new EncounterContext(encounter) { + Registry = encounterRegistry + }; + resolver.Resolve(option.events, context); - FinishEncounter(); + if(!context.ShouldStopEvents) { + FinishEncounter(); + } } public void CheckForEncounters(Vector3 position) { @@ -206,8 +264,14 @@ namespace Nox.Game { public void Tick() { if(adventureData.currentDay != lastCheckedDay) { + nextEncounterRollTime = UnityEngine.Random.Range(adventureData.currentTime, 1f); lastCheckedDay = adventureData.currentDay; - DailyEncounterCheck(lastKnownPosition); + encounterRolledToday = false; + } + + if(!encounterRolledToday && adventureData.currentTime >= nextEncounterRollTime) { + encounterRolledToday = true; + RollForEncounter(zoneSystem.QueryZone(lastKnownPosition)); } } diff --git a/Packages/com.jovian.encounter-system/Runtime/EncounterContext.cs b/Packages/com.jovian.encounter-system/Runtime/EncounterContext.cs index 55bbea9..a438185 100644 --- a/Packages/com.jovian.encounter-system/Runtime/EncounterContext.cs +++ b/Packages/com.jovian.encounter-system/Runtime/EncounterContext.cs @@ -2,6 +2,10 @@ namespace Jovian.EncounterSystem { /// Per-resolution scratch object passed to every event handler. Extend with fields as handlers need them. public class EncounterContext { public IEncounter CurrentEncounter { get; } + public EncounterRegistry Registry { get; set; } + + /// Set to true to halt processing of remaining events. + public bool ShouldStopEvents { get; set; } public EncounterContext(IEncounter currentEncounter) { CurrentEncounter = currentEncounter; diff --git a/Packages/com.jovian.encounter-system/Runtime/EncounterRegistry.cs b/Packages/com.jovian.encounter-system/Runtime/EncounterRegistry.cs index 5d93a71..e861ded 100644 --- a/Packages/com.jovian.encounter-system/Runtime/EncounterRegistry.cs +++ b/Packages/com.jovian.encounter-system/Runtime/EncounterRegistry.cs @@ -11,7 +11,9 @@ namespace Jovian.EncounterSystem { private readonly Dictionary encounters = new(); - public Dictionary GetEncounters() => encounters; + public Dictionary GetEncounters() { + return encounters; + } public void RegisterEncounter(IEncounter encounter) { var id = encounter?.EncounterDefinition?.internalId; @@ -38,19 +40,19 @@ namespace Jovian.EncounterSystem { return; } - for(int c = 0; c < encounterCollections.Length; c++) { + for(var c = 0; c < encounterCollections.Length; c++) { var collection = encounterCollections[c]; if(collection?.encounterTables == null) { continue; } - for(int t = 0; t < collection.encounterTables.Length; t++) { + for(var 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++) { + for(var i = 0; i < table.encounters.Count; i++) { RegisterEncounter(table.encounters[i]); } } @@ -67,12 +69,12 @@ namespace Jovian.EncounterSystem { return null; } - public IEncounter GetRandomEncounter(string encounterTableId) { - var table = GetEncounterTable(encounterTableId); - if(!table) { - return null; - } - var ec = table.encounters; + 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)]; } @@ -86,7 +88,7 @@ namespace Jovian.EncounterSystem { IEncounter selected = null; var seen = 0; - for(var i = 0; i < encounters.Count; i++) { + for(var i = 0; i < table.encounters.Count; i++) { var encounter = table.encounters[i]; if(encounter == null || encounter.GetType() != encounterKind.GetType()) { continue; diff --git a/Packages/com.jovian.encounter-system/Runtime/EncounterResolver.cs b/Packages/com.jovian.encounter-system/Runtime/EncounterResolver.cs index c523ca4..f6c3370 100644 --- a/Packages/com.jovian.encounter-system/Runtime/EncounterResolver.cs +++ b/Packages/com.jovian.encounter-system/Runtime/EncounterResolver.cs @@ -2,13 +2,12 @@ using System; using System.Collections.Generic; namespace Jovian.EncounterSystem { - /// Dispatches instances to per-type handlers. Unknown types are skipped. + /// Dispatches instances to per-type handlers. + /// Handlers return true to stop processing remaining events. public class EncounterResolver { - private readonly Dictionary> handlers = new(); + private readonly Dictionary> handlers = new(); - public void Register(Action 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). + public void Register(Func handler) where T : IEncounterEvent { handlers[typeof(T)] = (evt, ctx) => handler((T)evt, ctx); } @@ -17,22 +16,26 @@ namespace Jovian.EncounterSystem { } /// Indexed iteration over — avoids the boxed enumerator - /// that an IEnumerable<T> parameter would force. - /// is a List, which implements IReadOnlyList, so call sites just pass it directly. + /// that an IEnumerable<T> parameter would force. Stops when a handler + /// returns true or is set. public void Resolve(IReadOnlyList events, EncounterContext context) { if(events == null) { return; } var count = events.Count; - for(int i = 0; i < count; i++) { + for(var i = 0; i < count; i++) { var evt = events[i]; - if(evt == null) { + if(evt == null || context.ShouldStopEvents) { continue; } - if(handlers.TryGetValue(evt.GetType(), out var handler)) { - handler(evt, context); + if(!handlers.TryGetValue(evt.GetType(), out var handler)) { + continue; + } + + if(handler(evt, context)) { + context.ShouldStopEvents = true; } } } diff --git a/Packages/com.jovian.encounter-system/Runtime/IEncounterEvent.cs b/Packages/com.jovian.encounter-system/Runtime/IEncounterEvent.cs index 482b91f..2f05acf 100644 --- a/Packages/com.jovian.encounter-system/Runtime/IEncounterEvent.cs +++ b/Packages/com.jovian.encounter-system/Runtime/IEncounterEvent.cs @@ -24,4 +24,8 @@ namespace Jovian.EncounterSystem { public class GiveRewardEvent : IEncounterEvent { public Reward reward; } + + [Serializable] + public class AbandonEncounterEvent : IEncounterEvent { + } } diff --git a/Packages/com.jovian.encounter-system/Runtime/QuestProgress.cs b/Packages/com.jovian.encounter-system/Runtime/QuestProgress.cs index 2e6551a..0f549e5 100644 --- a/Packages/com.jovian.encounter-system/Runtime/QuestProgress.cs +++ b/Packages/com.jovian.encounter-system/Runtime/QuestProgress.cs @@ -14,6 +14,7 @@ namespace Jovian.EncounterSystem { public event Action QuestStarted; public event Action QuestAdvanced; public event Action QuestCompleted; + public event Action QuestAbandoned; public IReadOnlyCollection ResolvedIds => resolvedIds; @@ -76,6 +77,19 @@ namespace Jovian.EncounterSystem { } } + public void OnEncounterAbandoned(IEncounter encounter) { + if(encounter?.EncounterDefinition.Kind is not QuestKind questKind) { + return; + } + + var id = encounter.EncounterDefinition?.internalId; + if(string.IsNullOrEmpty(id) || !resolvedIds.Remove(id)) { + return; + } + + QuestAbandoned?.Invoke(encounter); + } + public void LoadResolvedIds(IEnumerable ids) { resolvedIds.Clear(); if(ids == null) {