531 lines
21 KiB
C#
531 lines
21 KiB
C#
using Barotrauma.Extensions;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace Barotrauma
|
|
{
|
|
class ScriptedEvent : Event
|
|
{
|
|
public sealed record TargetPredicate(
|
|
TargetPredicate.EntityType Type,
|
|
Predicate<Entity> Predicate)
|
|
{
|
|
public enum EntityType
|
|
{
|
|
Character,
|
|
Hull,
|
|
Item,
|
|
Structure,
|
|
Submarine
|
|
}
|
|
}
|
|
|
|
private readonly Dictionary<Identifier, List<TargetPredicate>> targetPredicates = new Dictionary<Identifier, List<TargetPredicate>>();
|
|
|
|
private readonly Dictionary<Identifier, List<Entity>> cachedTargets = new Dictionary<Identifier, List<Entity>>();
|
|
|
|
/// <summary>
|
|
/// How many targets were there when they were tagged for the first time? Can be used by some EventActions to check how many entities
|
|
/// there are still left (e.g. how much of the initial cargo still exists)
|
|
/// </summary>
|
|
private readonly Dictionary<Identifier, int> initialAmounts = new Dictionary<Identifier, int>();
|
|
|
|
private bool newEntitySpawned;
|
|
private int prevPlayerCount, prevBotCount;
|
|
private Character prevControlled;
|
|
|
|
public readonly OnRoundEndAction OnRoundEndAction;
|
|
|
|
private readonly Identifier[] requiredDestinationTypes;
|
|
public readonly bool RequireBeaconStation;
|
|
|
|
public readonly Identifier RequiredDestinationFaction;
|
|
|
|
public int CurrentActionIndex { get; private set; }
|
|
public List<EventAction> Actions { get; } = new List<EventAction>();
|
|
public Dictionary<Identifier, List<Entity>> Targets { get; } = new Dictionary<Identifier, List<Entity>>();
|
|
|
|
protected virtual IEnumerable<Identifier> NonActionChildElementNames => Enumerable.Empty<Identifier>();
|
|
|
|
public override string ToString()
|
|
{
|
|
return $"{nameof(ScriptedEvent)} ({prefab.Identifier})";
|
|
}
|
|
|
|
public ScriptedEvent(EventPrefab prefab, int seed) : base(prefab, seed)
|
|
{
|
|
foreach (var element in prefab.ConfigElement.Elements())
|
|
{
|
|
Identifier elementId = element.Name.ToIdentifier();
|
|
if (NonActionChildElementNames.Contains(elementId)) { continue; }
|
|
if (elementId == nameof(Barotrauma.OnRoundEndAction))
|
|
{
|
|
OnRoundEndAction = EventAction.Instantiate(this, element) as OnRoundEndAction;
|
|
continue;
|
|
}
|
|
if (elementId == "statuseffect")
|
|
{
|
|
DebugConsole.ThrowError($"Error in event prefab \"{prefab.Identifier}\". Status effect configured as an action. Please configure status effects as child elements of a StatusEffectAction.",
|
|
contentPackage: prefab.ContentPackage);
|
|
continue;
|
|
}
|
|
var action = EventAction.Instantiate(this, element);
|
|
if (action != null) { Actions.Add(action); }
|
|
}
|
|
|
|
if (!Actions.Any())
|
|
{
|
|
DebugConsole.ThrowError($"Scripted event \"{prefab.Identifier}\" has no actions. The event will do nothing.",
|
|
contentPackage: prefab.ContentPackage);
|
|
}
|
|
|
|
requiredDestinationTypes = prefab.ConfigElement.GetAttributeIdentifierArray("requireddestinationtypes", Array.Empty<Identifier>());
|
|
RequireBeaconStation = prefab.ConfigElement.GetAttributeBool("requirebeaconstation", false);
|
|
RequiredDestinationFaction = prefab.ConfigElement.GetAttributeIdentifier(nameof(RequiredDestinationFaction), Identifier.Empty);
|
|
|
|
var allActionsWithIndent = GetAllActions();
|
|
var allActions = allActionsWithIndent.Select(a => a.action);
|
|
|
|
//attempt to check if the event has ConversationActions with options that don't close the prompt and don't lead to any follow-up conversation
|
|
foreach (var action in allActions)
|
|
{
|
|
if (action is ConversationAction conversationAction && conversationAction.Options.Any())
|
|
{
|
|
int thisActionIndex = allActionsWithIndent.FindIndex(a => a.action == action);
|
|
int thisIndentationLevel = allActionsWithIndent[thisActionIndex].indent;
|
|
bool isLast = true;
|
|
|
|
//go through all the actions after this one
|
|
foreach (var actionWithIndent in allActionsWithIndent.Skip(thisActionIndex + 1))
|
|
{
|
|
//if it's an action with the same indentation level, it means it's a ConversationAction coming after this one
|
|
if (actionWithIndent.action is ConversationAction && actionWithIndent.indent == thisIndentationLevel)
|
|
{
|
|
isLast = false;
|
|
break;
|
|
}
|
|
//if the indentation level went back down, we've already searched everything inside this ConversationAction
|
|
if (actionWithIndent.indent < thisIndentationLevel) { break; }
|
|
}
|
|
if (isLast)
|
|
{
|
|
foreach (var option in conversationAction.Options)
|
|
{
|
|
if (!conversationAction.GetEndingOptions().Contains(conversationAction.Options.IndexOf(option)) &&
|
|
option.Actions.None(a =>
|
|
a is ConversationAction || HasConversationSubAction(a) ||
|
|
/* if there's a goto action explicitly set to end the conversation, assume it's intentional*/
|
|
a is GoTo { EndConversation: false }))
|
|
{
|
|
DebugConsole.AddWarning($"Potential error in event \"{prefab.Identifier}\": {nameof(ConversationAction)} ({conversationAction.Text}) has an option ({option.Text}) that doesn't end the conversation, but could not find any follow-ups to the conversation.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static bool HasConversationSubAction(EventAction action)
|
|
{
|
|
foreach (var subAction in action.GetSubActions())
|
|
{
|
|
if (subAction is ConversationAction) { return true; }
|
|
if (HasConversationSubAction(subAction)) { return true; }
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
foreach (var label in allActions.OfType<Label>())
|
|
{
|
|
if (allActions.None(a => a is GoTo gotoAction && label.Name == gotoAction.Name))
|
|
{
|
|
//this can be safe, because a label with no gotos leading to it does nothing (but it's still a sign that something's misconfigured)
|
|
DebugConsole.AddWarning($"Error in event \"{prefab.Identifier}\". Could not find a GoTo matching the Label \"{label.Name}\".",
|
|
contentPackage: prefab.ContentPackage);
|
|
}
|
|
}
|
|
|
|
foreach (var gotoAction in allActions.OfType<GoTo>())
|
|
{
|
|
int labelCount = allActions.Count(a => a is Label label && label.Name == gotoAction.Name);
|
|
if (labelCount == 0)
|
|
{
|
|
DebugConsole.ThrowError($"Error in event \"{prefab.Identifier}\". Could not find a label matching the GoTo \"{gotoAction.Name}\".",
|
|
contentPackage: prefab.ContentPackage);
|
|
}
|
|
else if (labelCount > 1)
|
|
{
|
|
DebugConsole.ThrowError($"Error in event \"{prefab.Identifier}\". Multiple labels with the name \"{gotoAction.Name}\".",
|
|
contentPackage: prefab.ContentPackage);
|
|
}
|
|
}
|
|
|
|
GameAnalyticsManager.AddDesignEvent($"ScriptedEvent:{prefab.Identifier}:Start");
|
|
}
|
|
|
|
public override string GetDebugInfo()
|
|
{
|
|
EventAction currentAction = !IsFinished ? Actions[CurrentActionIndex] : null;
|
|
|
|
string text = $"Finished: {IsFinished.ColorizeObject()}\n" +
|
|
$"Action index: {CurrentActionIndex.ColorizeObject()}\n" +
|
|
$"Current action: {currentAction?.ToDebugString() ?? ToolBox.ColorizeObject(null)}\n";
|
|
|
|
text += "All actions:\n";
|
|
text += GetAllActions().Aggregate(string.Empty, (current, action) => current + $"{new string(' ', action.indent * 6)}{action.action.ToDebugString()}\n");
|
|
|
|
text += "Targets:\n";
|
|
foreach (var (key, value) in Targets)
|
|
{
|
|
text += $" {key.ColorizeObject()}: {value.Aggregate(string.Empty, (current, entity) => current + $"{entity.ColorizeObject()} ")}\n";
|
|
}
|
|
return text;
|
|
}
|
|
|
|
public virtual string GetTextForReplacementElement(string tag)
|
|
{
|
|
if (tag.StartsWith("eventtag:"))
|
|
{
|
|
string targetTag = tag["eventtag:".Length..];
|
|
Entity target = GetTargets(targetTag.ToIdentifier()).FirstOrDefault();
|
|
if (target != null)
|
|
{
|
|
if (target is Item item) { return item.Name; }
|
|
if (target is Character character) { return character.Name; }
|
|
if (target is Hull hull) { return hull.DisplayName.Value; }
|
|
if (target is Submarine sub) { return sub.Info.DisplayName.Value; }
|
|
DebugConsole.AddWarning($"Failed to get the name of the event target {target} as a replacement for the tag {tag} in an event text.",
|
|
prefab.ContentPackage);
|
|
return target.ToString();
|
|
}
|
|
else
|
|
{
|
|
return $"[target \"{targetTag}\" not found]";
|
|
}
|
|
}
|
|
return string.Empty;
|
|
}
|
|
|
|
public virtual LocalizedString ReplaceVariablesInEventText(LocalizedString str)
|
|
{
|
|
return str;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds all actions in the ScriptedEvent using a depth-first search (recursively going through the subactions as well).
|
|
/// Returns a list of tuples where the first value is the indentation level (or "how deep in the hierarchy") the action is.
|
|
/// </summary>
|
|
public List<(int indent, EventAction action)> GetAllActions()
|
|
{
|
|
var list = new List<(int indent, EventAction action)>();
|
|
foreach (EventAction eventAction in Actions)
|
|
{
|
|
list.AddRange(FindActionsRecursive(eventAction));
|
|
}
|
|
return list;
|
|
|
|
static List<(int indent, EventAction action)> FindActionsRecursive(EventAction eventAction, int indent = 1)
|
|
{
|
|
var eventActions = new List<(int indent, EventAction action)> { (indent, eventAction) };
|
|
indent++;
|
|
foreach (var action in eventAction.GetSubActions())
|
|
{
|
|
eventActions.AddRange(FindActionsRecursive(action, indent));
|
|
}
|
|
return eventActions;
|
|
}
|
|
}
|
|
|
|
public void AddTarget(Identifier tag, Entity target)
|
|
{
|
|
if (target == null)
|
|
{
|
|
throw new ArgumentException($"Target was null (tag: {tag})");
|
|
}
|
|
if (target.Removed)
|
|
{
|
|
throw new ArgumentException($"Target has been removed (tag: {tag})");
|
|
}
|
|
if (Targets.ContainsKey(tag))
|
|
{
|
|
if (!Targets[tag].Contains(target))
|
|
{
|
|
Targets[tag].Add(target);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Targets.Add(tag, new List<Entity>() { target });
|
|
}
|
|
if (cachedTargets.ContainsKey(tag))
|
|
{
|
|
if (!cachedTargets[tag].Contains(target))
|
|
{
|
|
cachedTargets[tag].Add(target);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
cachedTargets.Add(tag, Targets[tag].ToList());
|
|
}
|
|
if (!initialAmounts.ContainsKey(tag))
|
|
{
|
|
initialAmounts.Add(tag, cachedTargets[tag].Count);
|
|
}
|
|
}
|
|
|
|
public void AddTargetPredicate(Identifier tag, TargetPredicate.EntityType entityType, Predicate<Entity> predicate)
|
|
{
|
|
if (!targetPredicates.ContainsKey(tag))
|
|
{
|
|
targetPredicates.Add(tag, new List<TargetPredicate>());
|
|
}
|
|
targetPredicates[tag].Add(new TargetPredicate(entityType, predicate));
|
|
// force re-search for this tag
|
|
if (cachedTargets.ContainsKey(tag))
|
|
{
|
|
cachedTargets.Remove(tag);
|
|
}
|
|
}
|
|
|
|
public int GetInitialTargetCount(Identifier tag)
|
|
{
|
|
if (initialAmounts.TryGetValue(tag, out int count))
|
|
{
|
|
return count;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
public IEnumerable<Entity> GetTargets(Identifier tag)
|
|
{
|
|
if (cachedTargets.ContainsKey(tag))
|
|
{
|
|
if (cachedTargets[tag].Any(t => t.Removed))
|
|
{
|
|
cachedTargets.Clear();
|
|
}
|
|
else
|
|
{
|
|
return cachedTargets[tag];
|
|
}
|
|
}
|
|
|
|
List<Entity> targetsToReturn = new List<Entity>();
|
|
if (Targets.ContainsKey(tag))
|
|
{
|
|
foreach (Entity e in Targets[tag])
|
|
{
|
|
if (e.Removed) { continue; }
|
|
targetsToReturn.Add(e);
|
|
}
|
|
}
|
|
if (targetPredicates.ContainsKey(tag))
|
|
{
|
|
foreach (var targetPredicate in targetPredicates[tag])
|
|
{
|
|
IEnumerable<Entity> entityList = targetPredicate.Type switch
|
|
{
|
|
TargetPredicate.EntityType.Character => Character.CharacterList,
|
|
TargetPredicate.EntityType.Item => Item.ItemList,
|
|
TargetPredicate.EntityType.Structure => MapEntity.MapEntityList.Where(m => m is Structure),
|
|
TargetPredicate.EntityType.Hull => Hull.HullList,
|
|
TargetPredicate.EntityType.Submarine => Submarine.Loaded,
|
|
_ => Entity.GetEntities(),
|
|
};
|
|
foreach (Entity entity in entityList)
|
|
{
|
|
if (targetsToReturn.Contains(entity)) { continue; }
|
|
if (targetPredicate.Predicate(entity))
|
|
{
|
|
targetsToReturn.Add(entity);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
foreach (WayPoint wayPoint in WayPoint.WayPointList)
|
|
{
|
|
if (wayPoint.Tags.Contains(tag)) { targetsToReturn.Add(wayPoint); }
|
|
}
|
|
if (Level.Loaded?.StartOutpost != null &&
|
|
Level.Loaded.StartOutpost.Info.OutpostNPCs.TryGetValue(tag, out List<Character> outpostNPCs))
|
|
{
|
|
foreach (Character npc in outpostNPCs)
|
|
{
|
|
if (npc.Removed || targetsToReturn.Contains(npc)) { continue; }
|
|
targetsToReturn.Add(npc);
|
|
}
|
|
}
|
|
|
|
cachedTargets.Add(tag, targetsToReturn);
|
|
if (!initialAmounts.ContainsKey(tag))
|
|
{
|
|
initialAmounts.Add(tag, targetsToReturn.Count);
|
|
}
|
|
return targetsToReturn;
|
|
}
|
|
|
|
public void InheritTags(Entity originalEntity, Entity newEntity)
|
|
{
|
|
foreach (var kvp in Targets)
|
|
{
|
|
if (kvp.Value.Contains(originalEntity))
|
|
{
|
|
kvp.Value.Add(newEntity);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void RemoveTag(Identifier tag)
|
|
{
|
|
if (tag.IsEmpty) { return; }
|
|
if (Targets.ContainsKey(tag)) { Targets.Remove(tag); }
|
|
if (cachedTargets.ContainsKey(tag)) { cachedTargets.Remove(tag); }
|
|
if (targetPredicates.ContainsKey(tag)) { targetPredicates.Remove(tag); }
|
|
}
|
|
|
|
public override void Update(float deltaTime)
|
|
{
|
|
int botCount = 0;
|
|
int playerCount = 0;
|
|
foreach (Character c in Character.CharacterList)
|
|
{
|
|
if (c.IsPlayer)
|
|
{
|
|
playerCount++;
|
|
}
|
|
else if (c.IsBot)
|
|
{
|
|
botCount++;
|
|
}
|
|
}
|
|
|
|
if (botCount != prevBotCount || playerCount != prevPlayerCount || prevControlled != Character.Controlled || NeedsToRefreshCachedTargets())
|
|
{
|
|
cachedTargets.Clear();
|
|
newEntitySpawned = false;
|
|
prevBotCount = botCount;
|
|
prevPlayerCount = playerCount;
|
|
prevControlled = Character.Controlled;
|
|
}
|
|
|
|
if (!Actions.Any())
|
|
{
|
|
Finish();
|
|
return;
|
|
}
|
|
|
|
var currentAction = Actions[CurrentActionIndex];
|
|
if (!currentAction.CanBeFinished())
|
|
{
|
|
Finish();
|
|
return;
|
|
}
|
|
|
|
string goTo = null;
|
|
if (currentAction.IsFinished(ref goTo))
|
|
{
|
|
if (string.IsNullOrEmpty(goTo))
|
|
{
|
|
CurrentActionIndex++;
|
|
}
|
|
else
|
|
{
|
|
CurrentActionIndex = -1;
|
|
Actions.ForEach(a => a.Reset());
|
|
for (int i = 0; i < Actions.Count; i++)
|
|
{
|
|
if (Actions[i].SetGoToTarget(goTo))
|
|
{
|
|
CurrentActionIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (CurrentActionIndex == -1)
|
|
{
|
|
DebugConsole.AddWarning($"Could not find the GoTo label \"{goTo}\" in the event \"{Prefab.Identifier}\". Ending the event.",
|
|
prefab.ContentPackage);
|
|
}
|
|
}
|
|
|
|
if (CurrentActionIndex >= Actions.Count || CurrentActionIndex < 0)
|
|
{
|
|
Finish();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
currentAction.Update(deltaTime);
|
|
}
|
|
}
|
|
|
|
private bool NeedsToRefreshCachedTargets()
|
|
{
|
|
if (newEntitySpawned) { return true; }
|
|
foreach (var cachedTargetList in cachedTargets.Values)
|
|
{
|
|
foreach (var target in cachedTargetList)
|
|
{
|
|
//one of the previously cached entities has been removed -> force refresh
|
|
if (target.Removed)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public void EntitySpawned(Entity entity)
|
|
{
|
|
if (newEntitySpawned) { return; }
|
|
if (entity is Character character &&
|
|
Level.Loaded?.StartOutpost != null &&
|
|
Level.Loaded.StartOutpost.Info.OutpostNPCs.Values.Any(npcList => npcList.Contains(character)))
|
|
{
|
|
newEntitySpawned = true;
|
|
return;
|
|
}
|
|
//new entity matches one of the existing predicates -> force refresh
|
|
foreach (var targetPredicateList in targetPredicates.Values)
|
|
{
|
|
foreach (var targetPredicate in targetPredicateList)
|
|
{
|
|
if (targetPredicate.Predicate(entity))
|
|
{
|
|
newEntitySpawned = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public override bool LevelMeetsRequirements()
|
|
{
|
|
var currLocation = GameMain.GameSession?.Campaign?.Map.CurrentLocation;
|
|
if (currLocation?.Connections == null) { return true; }
|
|
foreach (LocationConnection c in currLocation.Connections)
|
|
{
|
|
if (RequireBeaconStation && !c.LevelData.HasBeaconStation) { continue; }
|
|
|
|
var otherLocation = c.OtherLocation(currLocation);
|
|
if (!RequiredDestinationFaction.IsEmpty && otherLocation.Faction?.Prefab.Identifier != RequiredDestinationFaction) { continue; }
|
|
|
|
if (requiredDestinationTypes.Contains(Tags.AnyOutpost) && otherLocation.HasOutpost() && otherLocation.Type.IsAnyOutpost) { return true; }
|
|
if (requiredDestinationTypes.Any(t => otherLocation.Type.Identifier == t))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return RequiredDestinationFaction.IsEmpty && requiredDestinationTypes.None();
|
|
}
|
|
|
|
|
|
public override void Finish()
|
|
{
|
|
base.Finish();
|
|
GameAnalyticsManager.AddDesignEvent($"ScriptedEvent:{prefab.Identifier}:Finished:{CurrentActionIndex}");
|
|
}
|
|
}
|
|
}
|