performance optimizations

This commit is contained in:
Sebastian Bularca
2026-04-20 08:07:24 +02:00
parent f055250ca6
commit 2445781f33
7 changed files with 152 additions and 48 deletions

View File

@@ -34,12 +34,13 @@ namespace Jovian.EncounterSystem {
return; return;
} }
cache = new Dictionary<string, string>(); cache = new Dictionary<string, string>(lines?.Count ?? 0);
if(lines == null) { if(lines == null) {
return; return;
} }
foreach(var line in lines) { for(int i = 0; i < lines.Count; i++) {
var line = lines[i];
if(line != null && !string.IsNullOrEmpty(line.id)) { if(line != null && !string.IsNullOrEmpty(line.id)) {
cache[line.id] = line.text; cache[line.id] = line.text;
} }

View File

@@ -7,18 +7,13 @@ namespace Jovian.EncounterSystem {
public EncounterTable table; public EncounterTable table;
public string internalId; 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() { public IEncounter Resolve() {
if(table == null || table.encounters == null || string.IsNullOrEmpty(internalId)) { if(table == null || string.IsNullOrEmpty(internalId)) {
return null; return null;
} }
return table.Resolve(internalId);
foreach(var encounter in table.encounters) {
if(encounter?.EncounterDefinition?.internalId == internalId) {
return encounter;
}
}
return null;
} }
} }
} }

View File

@@ -14,11 +14,19 @@ namespace Jovian.EncounterSystem {
public Dictionary<string, IEncounter> GetEncounters() => encounters; public Dictionary<string, IEncounter> GetEncounters() => encounters;
public void RegisterEncounter(IEncounter encounter) { public void RegisterEncounter(IEncounter encounter) {
encounters?.TryAdd(encounter?.EncounterDefinition?.internalId, encounter); var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return;
}
encounters.TryAdd(id, encounter);
} }
public void UnregisterEncounter(IEncounter encounter) { public void UnregisterEncounter(IEncounter encounter) {
encounters.Remove(encounter.EncounterDefinition.internalId); var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) {
return;
}
encounters.Remove(id);
} }
public void ClearEncounters() { public void ClearEncounters() {
@@ -26,10 +34,24 @@ namespace Jovian.EncounterSystem {
} }
public void PopulateEncounters() { public void PopulateEncounters() {
foreach(var collection in encounterCollections) { if(encounterCollections == null) {
foreach(var encounter in collection.encounterTables) { return;
foreach(var encounterInstance in encounter.encounters) { }
RegisterEncounter(encounterInstance);
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]);
} }
} }
} }

View File

@@ -16,12 +16,17 @@ namespace Jovian.EncounterSystem {
handlers.Remove(typeof(T)); handlers.Remove(typeof(T));
} }
public void Resolve(IEnumerable<IEncounterEvent> events, EncounterContext context) { /// <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) { if(events == null) {
return; return;
} }
foreach(var evt in events) { var count = events.Count;
for(int i = 0; i < count; i++) {
var evt = events[i];
if(evt == null) { if(evt == null) {
continue; continue;
} }

View File

@@ -8,6 +8,24 @@ namespace Jovian.EncounterSystem {
public string id; public string id;
public List<Encounter> encounters; 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.TryGetValue(internalId, out var encounter) ? encounter : null;
}
/// <summary>Force the id lookup cache to rebuild on next use.</summary>
public void InvalidateCache() {
idCache = null;
}
public IEncounter GetRandomEncounter() { public IEncounter GetRandomEncounter() {
if(encounters == null || encounters.Count == 0) { if(encounters == null || encounters.Count == 0) {
return null; return null;
@@ -16,50 +34,92 @@ namespace Jovian.EncounterSystem {
return encounters[UnityEngine.Random.Range(0, encounters.Count)]; return encounters[UnityEngine.Random.Range(0, encounters.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(Type type) { public IEncounter GetRandomEncounter(Type type) {
if(encounters == null || encounters.Count == 0 || type == null) {
return null;
}
IEncounter selected = null;
var seen = 0;
for(int i = 0; i < encounters.Count; i++) {
var encounter = encounters[i];
if(encounter == null || encounter.GetType() != type) {
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) {
if(encounters == null || encounters.Count == 0) { if(encounters == null || encounters.Count == 0) {
return null; return null;
} }
if(filter == 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(); return GetRandomEncounter();
} }
var pool = encounters.FindAll(filter); IEncounter selected = null;
if(pool.Count == 0) { var seen = 0;
return null; for(int i = 0; i < encounters.Count; i++) {
var encounter = encounters[i];
if(encounter == null || !filter(encounter)) {
continue;
}
seen++;
if(UnityEngine.Random.Range(0, seen) == 0) {
selected = encounter;
}
}
return selected;
}
private void EnsureCache() {
if(idCache != null) {
return;
} }
return pool[UnityEngine.Random.Range(0, pool.Count)]; idCache = new Dictionary<string, Encounter>(encounters?.Count ?? 0);
if(encounters == null) {
return;
}
for(int 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 #if UNITY_EDITOR
// Unity's inspector "+" duplicates the previous list element, including nested internalId // Unity's inspector "+" duplicates the previous list element, including nested internalId
// GUIDs. Regenerate any duplicates so every encounter carries a unique internalId. // GUIDs. Regenerate any duplicates so every encounter carries a unique internalId.
private void OnValidate() { private void OnValidate() {
InvalidateCache();
if(encounters == null) { if(encounters == null) {
return; return;
} }
var seen = new HashSet<string>(); var seen = new HashSet<string>(encounters.Count);
var changed = false; var changed = false;
foreach(var encounter in encounters) { for(int i = 0; i < encounters.Count; i++) {
var encounter = encounters[i];
if(encounter?.EncounterDefinition == null) { if(encounter?.EncounterDefinition == null) {
continue; continue;
} }
var id = encounter.EncounterDefinition.internalId; var entryId = encounter.EncounterDefinition.internalId;
if(string.IsNullOrEmpty(id) || !seen.Add(id)) { if(string.IsNullOrEmpty(entryId) || !seen.Add(entryId)) {
encounter.EncounterDefinition.internalId = Guid.NewGuid().ToString(); encounter.EncounterDefinition.internalId = Guid.NewGuid().ToString();
seen.Add(encounter.EncounterDefinition.internalId); seen.Add(encounter.EncounterDefinition.internalId);
changed = true; changed = true;

View File

@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace Jovian.EncounterSystem { namespace Jovian.EncounterSystem {
public enum QuestLogEventType { public enum QuestLogEventType {
@@ -46,9 +45,12 @@ namespace Jovian.EncounterSystem {
} }
public IEnumerable<string> ResolvedEncounterIds() { public IEnumerable<string> ResolvedEncounterIds() {
return entries for(int i = 0; i < entries.Count; i++) {
.Select(entry => entry.encounterInternalId) var id = entries[i].encounterInternalId;
.Where(id => !string.IsNullOrEmpty(id)); if(!string.IsNullOrEmpty(id)) {
yield return id;
}
}
} }
private void Record(QuestLogEventType type, IEncounter from, IEncounter to) { private void Record(QuestLogEventType type, IEncounter from, IEncounter to) {

View File

@@ -8,8 +8,8 @@ namespace Jovian.EncounterSystem {
/// construction; rolling and advancement are O(1). /// construction; rolling and advancement are O(1).
/// </summary> /// </summary>
public class QuestProgress { public class QuestProgress {
private readonly HashSet<string> resolvedIds = new(); private readonly HashSet<string> resolvedIds;
private readonly Dictionary<string, IEncounter> predecessorOf = new(); private readonly Dictionary<string, IEncounter> predecessorOf;
public event Action<IEncounter> QuestStarted; public event Action<IEncounter> QuestStarted;
public event Action<IEncounter, IEncounter> QuestAdvanced; public event Action<IEncounter, IEncounter> QuestAdvanced;
@@ -18,9 +18,26 @@ namespace Jovian.EncounterSystem {
public IReadOnlyCollection<string> ResolvedIds => resolvedIds; public IReadOnlyCollection<string> ResolvedIds => resolvedIds;
public QuestProgress(EncountersCollection encountersCollection) { public QuestProgress(EncountersCollection encountersCollection) {
var capacity = EstimateEncounterCount(encountersCollection);
resolvedIds = new HashSet<string>(capacity);
predecessorOf = new Dictionary<string, IEncounter>(capacity);
IndexQuests(encountersCollection); 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) { public bool IsGated(IEncounter encounter) {
var id = encounter?.EncounterDefinition?.internalId; var id = encounter?.EncounterDefinition?.internalId;
if(string.IsNullOrEmpty(id)) { if(string.IsNullOrEmpty(id)) {
@@ -77,13 +94,15 @@ namespace Jovian.EncounterSystem {
return; return;
} }
foreach(var table in collection.encounterTables) { for(int t = 0; t < collection.encounterTables.Length; t++) {
var table = collection.encounterTables[t];
if(table?.encounters == null) { if(table?.encounters == null) {
continue; continue;
} }
foreach(var encounter in table.encounters) { for(int i = 0; i < table.encounters.Count; i++) {
if(encounter?.EncounterDefinition.Kind is not QuestKind questKind) { var encounter = table.encounters[i];
if(encounter?.EncounterDefinition?.Kind is not QuestKind questKind) {
continue; continue;
} }