merge actor behavior system
This commit is contained in:
@@ -0,0 +1,332 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.ActorBehavior
|
||||||
|
{
|
||||||
|
public sealed class BehaviorConfigCatalog
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, BehaviorSpec> _behaviors;
|
||||||
|
private readonly Dictionary<string, FlyObjectSpec> _flyObjects;
|
||||||
|
private readonly Dictionary<string, BuffSpec> _buffs;
|
||||||
|
|
||||||
|
private BehaviorConfigCatalog(string type, int version,
|
||||||
|
Dictionary<string, BehaviorSpec> behaviors,
|
||||||
|
Dictionary<string, FlyObjectSpec> flyObjects,
|
||||||
|
Dictionary<string, BuffSpec> buffs)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
Version = version;
|
||||||
|
_behaviors = behaviors;
|
||||||
|
_flyObjects = flyObjects;
|
||||||
|
_buffs = buffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Type { get; }
|
||||||
|
public int Version { get; }
|
||||||
|
public IReadOnlyDictionary<string, BehaviorSpec> Behaviors => _behaviors;
|
||||||
|
public IReadOnlyDictionary<string, FlyObjectSpec> FlyObjects => _flyObjects;
|
||||||
|
public IReadOnlyDictionary<string, BuffSpec> Buffs => _buffs;
|
||||||
|
|
||||||
|
public static BehaviorConfigCatalog LoadFromJson(string type, string skillJson, string flyObjectJson, string buffJson)
|
||||||
|
{
|
||||||
|
JsonObject skillRoot = BehaviorJson.ParseObject(skillJson);
|
||||||
|
JsonObject flyRoot = BehaviorJson.ParseObject(flyObjectJson);
|
||||||
|
JsonObject buffRoot = BehaviorJson.ParseObject(buffJson);
|
||||||
|
|
||||||
|
string skillType = skillRoot.RequiredString("type");
|
||||||
|
string flyType = flyRoot.RequiredString("type");
|
||||||
|
string buffType = buffRoot.RequiredString("type");
|
||||||
|
if (string.IsNullOrEmpty(type)) type = skillType;
|
||||||
|
if (skillType != type || flyType != type || buffType != type)
|
||||||
|
throw new InvalidOperationException("Behavior config type mismatch: expected " + type);
|
||||||
|
|
||||||
|
int version = skillRoot.OptionalInt("version", 1);
|
||||||
|
int flyVersion = flyRoot.OptionalInt("version", 1);
|
||||||
|
int buffVersion = buffRoot.OptionalInt("version", 1);
|
||||||
|
if (flyVersion != version || buffVersion != version)
|
||||||
|
throw new InvalidOperationException("Behavior config version mismatch: expected " + version);
|
||||||
|
var flyObjects = ParseFlyObjects(flyRoot.OptionalArray("flyObjects"));
|
||||||
|
var buffs = ParseBuffs(buffRoot.OptionalArray("buffs"));
|
||||||
|
var behaviors = ParseBehaviors(skillRoot.OptionalArray("behaviors"));
|
||||||
|
|
||||||
|
var catalog = new BehaviorConfigCatalog(type, version, behaviors, flyObjects, buffs);
|
||||||
|
catalog.Validate();
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, BehaviorSpec> ParseBehaviors(JsonArray array)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, BehaviorSpec>(StringComparer.Ordinal);
|
||||||
|
for (int i = 0; i < array.Count; i++)
|
||||||
|
{
|
||||||
|
JsonObject obj = array[i].AsObject();
|
||||||
|
var spec = new BehaviorSpec
|
||||||
|
{
|
||||||
|
Id = obj.RequiredString("id"),
|
||||||
|
Duration = obj.OptionalFloat("duration"),
|
||||||
|
CanBeInterrupted = obj.OptionalBool("canBeInterrupted"),
|
||||||
|
Input = obj.OptionalString("input")
|
||||||
|
};
|
||||||
|
spec.Timeline = ParseTimeline(obj.OptionalArray("timeline"));
|
||||||
|
spec.LogicEffects = ParseLogicEffects(obj.OptionalArray("logicEffects"));
|
||||||
|
AddUnique(result, spec.Id, spec, "behavior");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, FlyObjectSpec> ParseFlyObjects(JsonArray array)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, FlyObjectSpec>(StringComparer.Ordinal);
|
||||||
|
for (int i = 0; i < array.Count; i++)
|
||||||
|
{
|
||||||
|
JsonObject obj = array[i].AsObject();
|
||||||
|
var spec = new FlyObjectSpec
|
||||||
|
{
|
||||||
|
Id = obj.RequiredString("id"),
|
||||||
|
Duration = obj.OptionalFloat("duration"),
|
||||||
|
Movement = ParseMovement(obj.OptionalObject("movement")),
|
||||||
|
Collision = ParseCollision(obj.OptionalObject("collision")),
|
||||||
|
Timeline = ParseTimeline(obj.OptionalArray("timeline")),
|
||||||
|
LogicEffects = ParseLogicEffects(obj.OptionalArray("logicEffects"))
|
||||||
|
};
|
||||||
|
AddUnique(result, spec.Id, spec, "flyObject");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, BuffSpec> ParseBuffs(JsonArray array)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, BuffSpec>(StringComparer.Ordinal);
|
||||||
|
for (int i = 0; i < array.Count; i++)
|
||||||
|
{
|
||||||
|
JsonObject obj = array[i].AsObject();
|
||||||
|
var spec = new BuffSpec
|
||||||
|
{
|
||||||
|
Id = obj.RequiredString("id"),
|
||||||
|
Duration = obj.OptionalFloat("duration"),
|
||||||
|
TickInterval = obj.OptionalFloat("tickInterval"),
|
||||||
|
RepeatCount = obj.OptionalInt("repeatCount"),
|
||||||
|
Stacking = ParseEnum(obj.OptionalString("stacking"), BuffStackingKind.RefreshDuration),
|
||||||
|
LogicEffects = ParseLogicEffects(obj.OptionalArray("logicEffects"))
|
||||||
|
};
|
||||||
|
AddUnique(result, spec.Id, spec, "buff");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TimelineEntrySpec> ParseTimeline(JsonArray array)
|
||||||
|
{
|
||||||
|
var result = new List<TimelineEntrySpec>();
|
||||||
|
for (int i = 0; i < array.Count; i++)
|
||||||
|
{
|
||||||
|
JsonObject obj = array[i].AsObject();
|
||||||
|
var spec = new TimelineEntrySpec
|
||||||
|
{
|
||||||
|
Time = obj.OptionalFloat("time"),
|
||||||
|
Kind = ParseEnum<TimelineEntryKind>(obj.RequiredString("kind")),
|
||||||
|
Trigger = ParseEnum(obj.OptionalString("trigger"), TimelineTriggerKind.Time),
|
||||||
|
Duration = obj.OptionalFloat("duration"),
|
||||||
|
Animation = obj.OptionalString("animation"),
|
||||||
|
Prefab = obj.OptionalString("prefab"),
|
||||||
|
EffectId = obj.OptionalString("effectId"),
|
||||||
|
FlyObjectId = obj.OptionalString("flyObjectId"),
|
||||||
|
BuffId = obj.OptionalString("buffId")
|
||||||
|
};
|
||||||
|
result.Add(spec);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<LogicEffectSpec> ParseLogicEffects(JsonArray array)
|
||||||
|
{
|
||||||
|
var result = new List<LogicEffectSpec>();
|
||||||
|
for (int i = 0; i < array.Count; i++)
|
||||||
|
{
|
||||||
|
JsonObject obj = array[i].AsObject();
|
||||||
|
var spec = new LogicEffectSpec
|
||||||
|
{
|
||||||
|
Id = obj.RequiredString("id"),
|
||||||
|
Shape = ParseEnum<LogicShapeKind>(obj.RequiredString("shape")),
|
||||||
|
Target = ParseEnum<LogicTargetKind>(obj.OptionalString("target"), LogicTargetKind.Enemy),
|
||||||
|
Radius = obj.OptionalFloat("radius"),
|
||||||
|
MinRadius = obj.OptionalFloat("minRadius"),
|
||||||
|
Angle = obj.OptionalFloat("angle"),
|
||||||
|
Width = obj.OptionalFloat("width"),
|
||||||
|
Effects = ParseEffectOps(obj.OptionalArray("effects"))
|
||||||
|
};
|
||||||
|
result.Add(spec);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<LogicEffectOpSpec> ParseEffectOps(JsonArray array)
|
||||||
|
{
|
||||||
|
var result = new List<LogicEffectOpSpec>();
|
||||||
|
for (int i = 0; i < array.Count; i++)
|
||||||
|
{
|
||||||
|
JsonObject obj = array[i].AsObject();
|
||||||
|
result.Add(new LogicEffectOpSpec
|
||||||
|
{
|
||||||
|
Type = ParseEnum<LogicEffectOpKind>(obj.RequiredString("type")),
|
||||||
|
Attribute = obj.OptionalString("attribute"),
|
||||||
|
Value = obj.OptionalFloat("value"),
|
||||||
|
BuffId = obj.OptionalString("buffId"),
|
||||||
|
FlyObjectId = obj.OptionalString("flyObjectId")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlyMovementSpec ParseMovement(JsonObject obj)
|
||||||
|
{
|
||||||
|
return new FlyMovementSpec
|
||||||
|
{
|
||||||
|
Kind = ParseEnum(obj.OptionalString("kind"), FlyMovementKind.Line),
|
||||||
|
Speed = obj.OptionalFloat("speed"),
|
||||||
|
Gravity = obj.OptionalFloat("gravity", 9.8f)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CollisionSpec ParseCollision(JsonObject obj)
|
||||||
|
{
|
||||||
|
return new CollisionSpec
|
||||||
|
{
|
||||||
|
Shape = ParseEnum(obj.OptionalString("shape"), CollisionShapeKind.Sphere),
|
||||||
|
Radius = obj.OptionalFloat("radius"),
|
||||||
|
HitLimit = obj.OptionalInt("hitLimit", 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Validate()
|
||||||
|
{
|
||||||
|
foreach (BehaviorSpec behavior in _behaviors.Values)
|
||||||
|
{
|
||||||
|
ValidateDuration(behavior.Duration, "behavior " + behavior.Id);
|
||||||
|
ValidateTimeline(behavior.Timeline, behavior.LogicEffects, "behavior " + behavior.Id);
|
||||||
|
ValidateLogicEffects(behavior.LogicEffects, "behavior " + behavior.Id);
|
||||||
|
}
|
||||||
|
foreach (FlyObjectSpec fly in _flyObjects.Values)
|
||||||
|
{
|
||||||
|
ValidateDuration(fly.Duration, "flyObject " + fly.Id);
|
||||||
|
if (fly.Movement.Speed < 0f)
|
||||||
|
throw new InvalidOperationException("Invalid speed for flyObject " + fly.Id);
|
||||||
|
ValidateDuration(fly.Collision.Radius, "flyObject collision " + fly.Id);
|
||||||
|
if (fly.Collision.HitLimit < 0)
|
||||||
|
throw new InvalidOperationException("Invalid hitLimit for flyObject " + fly.Id);
|
||||||
|
ValidateTimeline(fly.Timeline, fly.LogicEffects, "flyObject " + fly.Id);
|
||||||
|
ValidateLogicEffects(fly.LogicEffects, "flyObject " + fly.Id);
|
||||||
|
}
|
||||||
|
foreach (BuffSpec buff in _buffs.Values)
|
||||||
|
{
|
||||||
|
ValidateDuration(buff.Duration, "buff " + buff.Id);
|
||||||
|
if (buff.TickInterval < 0f) throw new InvalidOperationException("Invalid tickInterval for buff " + buff.Id);
|
||||||
|
ValidateLogicEffects(buff.LogicEffects, "buff " + buff.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateTimeline(List<TimelineEntrySpec> timeline, List<LogicEffectSpec> logicEffects, string owner)
|
||||||
|
{
|
||||||
|
var localEffects = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (LogicEffectSpec effect in logicEffects)
|
||||||
|
AddUnique(localEffects, effect.Id, "logicEffect");
|
||||||
|
|
||||||
|
foreach (TimelineEntrySpec entry in timeline)
|
||||||
|
{
|
||||||
|
if (entry.Time < 0f) throw new InvalidOperationException("Negative timeline time in " + owner);
|
||||||
|
if (entry.Kind == TimelineEntryKind.Animation && string.IsNullOrEmpty(entry.Animation))
|
||||||
|
throw new InvalidOperationException("Missing animation for timeline entry in " + owner);
|
||||||
|
if (entry.Kind == TimelineEntryKind.Effect && string.IsNullOrEmpty(entry.Prefab))
|
||||||
|
throw new InvalidOperationException("Missing prefab for timeline entry in " + owner);
|
||||||
|
if (entry.Kind == TimelineEntryKind.LogicEffect)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(entry.EffectId))
|
||||||
|
throw new InvalidOperationException("Missing effectId for timeline entry in " + owner);
|
||||||
|
if (!localEffects.Contains(entry.EffectId))
|
||||||
|
throw new InvalidOperationException("Missing logic effect reference " + entry.EffectId + " in " + owner);
|
||||||
|
}
|
||||||
|
if (entry.Kind == TimelineEntryKind.Projectile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(entry.FlyObjectId))
|
||||||
|
throw new InvalidOperationException("Missing flyObjectId for timeline entry in " + owner);
|
||||||
|
if (!_flyObjects.ContainsKey(entry.FlyObjectId))
|
||||||
|
throw new InvalidOperationException("Missing flyObject reference " + entry.FlyObjectId + " in " + owner);
|
||||||
|
}
|
||||||
|
if (entry.Kind == TimelineEntryKind.Buff)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(entry.BuffId))
|
||||||
|
throw new InvalidOperationException("Missing buffId for timeline entry in " + owner);
|
||||||
|
if (!_buffs.ContainsKey(entry.BuffId))
|
||||||
|
throw new InvalidOperationException("Missing buff reference " + entry.BuffId + " in " + owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateLogicEffects(List<LogicEffectSpec> effects, string owner)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (LogicEffectSpec effect in effects)
|
||||||
|
{
|
||||||
|
AddUnique(ids, effect.Id, "logicEffect");
|
||||||
|
if (effect.Shape != LogicShapeKind.Locked && effect.Radius < 0f)
|
||||||
|
throw new InvalidOperationException("Invalid radius for " + effect.Id + " in " + owner);
|
||||||
|
if (effect.Shape == LogicShapeKind.Ring && (effect.MinRadius < 0f || effect.MinRadius > effect.Radius))
|
||||||
|
throw new InvalidOperationException("Invalid minRadius for " + effect.Id + " in " + owner);
|
||||||
|
if (effect.Shape == LogicShapeKind.Sector && (effect.Angle <= 0f || effect.Angle > 360f))
|
||||||
|
throw new InvalidOperationException("Invalid sector angle for " + effect.Id + " in " + owner);
|
||||||
|
if (effect.Shape == LogicShapeKind.Line && effect.Width < 0f)
|
||||||
|
throw new InvalidOperationException("Invalid width for " + effect.Id + " in " + owner);
|
||||||
|
foreach (LogicEffectOpSpec op in effect.Effects)
|
||||||
|
{
|
||||||
|
if (op.Type == LogicEffectOpKind.Buff)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(op.BuffId))
|
||||||
|
throw new InvalidOperationException("Missing buffId in " + owner);
|
||||||
|
if (!_buffs.ContainsKey(op.BuffId))
|
||||||
|
throw new InvalidOperationException("Missing buff reference " + op.BuffId + " in " + owner);
|
||||||
|
}
|
||||||
|
if (op.Type == LogicEffectOpKind.Projectile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(op.FlyObjectId))
|
||||||
|
throw new InvalidOperationException("Missing flyObjectId in " + owner);
|
||||||
|
if (!_flyObjects.ContainsKey(op.FlyObjectId))
|
||||||
|
throw new InvalidOperationException("Missing flyObject reference " + op.FlyObjectId + " in " + owner);
|
||||||
|
}
|
||||||
|
if (op.Type == LogicEffectOpKind.Attribute && string.IsNullOrEmpty(op.Attribute))
|
||||||
|
throw new InvalidOperationException("Missing attribute in " + owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateDuration(float value, string owner)
|
||||||
|
{
|
||||||
|
if (value < 0f) throw new InvalidOperationException("Invalid duration for " + owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddUnique<T>(Dictionary<string, T> dict, string id, T value, string kind)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("Missing " + kind + " id");
|
||||||
|
if (dict.ContainsKey(id)) throw new InvalidOperationException("Duplicate " + kind + " id: " + id);
|
||||||
|
dict.Add(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddUnique(HashSet<string> set, string id, string kind)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("Missing " + kind + " id");
|
||||||
|
if (!set.Add(id)) throw new InvalidOperationException("Duplicate " + kind + " id: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T ParseEnum<T>(string value) where T : struct
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
throw new InvalidOperationException("Missing enum value for " + typeof(T).Name);
|
||||||
|
if (Enum.TryParse(value, true, out T parsed))
|
||||||
|
return parsed;
|
||||||
|
throw new InvalidOperationException("Unsupported enum value " + value + " for " + typeof(T).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T ParseEnum<T>(string value, T defaultValue) where T : struct
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(value) ? defaultValue : ParseEnum<T>(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.ActorBehavior
|
||||||
|
{
|
||||||
|
public sealed class BehaviorSpec
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public float Duration;
|
||||||
|
public bool CanBeInterrupted;
|
||||||
|
public string Input;
|
||||||
|
public List<TimelineEntrySpec> Timeline = new List<TimelineEntrySpec>();
|
||||||
|
public List<LogicEffectSpec> LogicEffects = new List<LogicEffectSpec>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FlyObjectSpec
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public float Duration;
|
||||||
|
public FlyMovementSpec Movement = new FlyMovementSpec();
|
||||||
|
public CollisionSpec Collision = new CollisionSpec();
|
||||||
|
public List<TimelineEntrySpec> Timeline = new List<TimelineEntrySpec>();
|
||||||
|
public List<LogicEffectSpec> LogicEffects = new List<LogicEffectSpec>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuffSpec
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public float Duration;
|
||||||
|
public float TickInterval;
|
||||||
|
public int RepeatCount;
|
||||||
|
public BuffStackingKind Stacking;
|
||||||
|
public List<LogicEffectSpec> LogicEffects = new List<LogicEffectSpec>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TimelineEntrySpec
|
||||||
|
{
|
||||||
|
public float Time;
|
||||||
|
public TimelineEntryKind Kind;
|
||||||
|
public TimelineTriggerKind Trigger;
|
||||||
|
public float Duration;
|
||||||
|
public string Animation;
|
||||||
|
public string Prefab;
|
||||||
|
public string EffectId;
|
||||||
|
public string FlyObjectId;
|
||||||
|
public string BuffId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LogicEffectSpec
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
public LogicShapeKind Shape;
|
||||||
|
public LogicTargetKind Target;
|
||||||
|
public float Radius;
|
||||||
|
public float MinRadius;
|
||||||
|
public float Angle;
|
||||||
|
public float Width;
|
||||||
|
public List<LogicEffectOpSpec> Effects = new List<LogicEffectOpSpec>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LogicEffectOpSpec
|
||||||
|
{
|
||||||
|
public LogicEffectOpKind Type;
|
||||||
|
public string Attribute;
|
||||||
|
public float Value;
|
||||||
|
public string BuffId;
|
||||||
|
public string FlyObjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FlyMovementSpec
|
||||||
|
{
|
||||||
|
public FlyMovementKind Kind;
|
||||||
|
public float Speed;
|
||||||
|
public float Gravity = 9.8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CollisionSpec
|
||||||
|
{
|
||||||
|
public CollisionShapeKind Shape;
|
||||||
|
public float Radius;
|
||||||
|
public int HitLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.ActorBehavior
|
||||||
|
{
|
||||||
|
internal abstract class JsonValue
|
||||||
|
{
|
||||||
|
public virtual JsonObject AsObject() { throw new InvalidOperationException("JSON value is not an object"); }
|
||||||
|
public virtual JsonArray AsArray() { throw new InvalidOperationException("JSON value is not an array"); }
|
||||||
|
public virtual string AsString() { throw new InvalidOperationException("JSON value is not a string"); }
|
||||||
|
public virtual double AsNumber() { throw new InvalidOperationException("JSON value is not a number"); }
|
||||||
|
public virtual bool AsBool() { throw new InvalidOperationException("JSON value is not a bool"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JsonObject : JsonValue
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, JsonValue> _values = new Dictionary<string, JsonValue>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public override JsonObject AsObject() => this;
|
||||||
|
|
||||||
|
public void Add(string key, JsonValue value)
|
||||||
|
{
|
||||||
|
_values[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(string key, out JsonValue value)
|
||||||
|
{
|
||||||
|
return _values.TryGetValue(key, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RequiredString(string key)
|
||||||
|
{
|
||||||
|
if (!TryGet(key, out JsonValue value)) throw new InvalidOperationException("Missing JSON field: " + key);
|
||||||
|
return value.AsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string OptionalString(string key)
|
||||||
|
{
|
||||||
|
return TryGet(key, out JsonValue value) && !(value is JsonNull) ? value.AsString() : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float OptionalFloat(string key, float defaultValue = 0f)
|
||||||
|
{
|
||||||
|
return TryGet(key, out JsonValue value) && !(value is JsonNull) ? (float)value.AsNumber() : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int OptionalInt(string key, int defaultValue = 0)
|
||||||
|
{
|
||||||
|
return TryGet(key, out JsonValue value) && !(value is JsonNull) ? (int)value.AsNumber() : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool OptionalBool(string key, bool defaultValue = false)
|
||||||
|
{
|
||||||
|
return TryGet(key, out JsonValue value) && !(value is JsonNull) ? value.AsBool() : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonArray OptionalArray(string key)
|
||||||
|
{
|
||||||
|
return TryGet(key, out JsonValue value) && !(value is JsonNull) ? value.AsArray() : new JsonArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonObject OptionalObject(string key)
|
||||||
|
{
|
||||||
|
return TryGet(key, out JsonValue value) && !(value is JsonNull) ? value.AsObject() : new JsonObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JsonArray : JsonValue
|
||||||
|
{
|
||||||
|
private readonly List<JsonValue> _values = new List<JsonValue>();
|
||||||
|
|
||||||
|
public override JsonArray AsArray() => this;
|
||||||
|
public int Count => _values.Count;
|
||||||
|
public JsonValue this[int index] => _values[index];
|
||||||
|
public void Add(JsonValue value) => _values.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JsonString : JsonValue
|
||||||
|
{
|
||||||
|
private readonly string _value;
|
||||||
|
public JsonString(string value) { _value = value ?? string.Empty; }
|
||||||
|
public override string AsString() => _value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JsonNumber : JsonValue
|
||||||
|
{
|
||||||
|
private readonly double _value;
|
||||||
|
public JsonNumber(double value) { _value = value; }
|
||||||
|
public override double AsNumber() => _value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JsonBool : JsonValue
|
||||||
|
{
|
||||||
|
private readonly bool _value;
|
||||||
|
public JsonBool(bool value) { _value = value; }
|
||||||
|
public override bool AsBool() => _value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class JsonNull : JsonValue
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class BehaviorJson
|
||||||
|
{
|
||||||
|
public static JsonObject ParseObject(string json)
|
||||||
|
{
|
||||||
|
var parser = new Parser(json);
|
||||||
|
JsonValue value = parser.Parse();
|
||||||
|
return value.AsObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Parser
|
||||||
|
{
|
||||||
|
private readonly string _json;
|
||||||
|
private int _pos;
|
||||||
|
|
||||||
|
public Parser(string json)
|
||||||
|
{
|
||||||
|
_json = json ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonValue Parse()
|
||||||
|
{
|
||||||
|
SkipWhite();
|
||||||
|
JsonValue value = ParseValue();
|
||||||
|
SkipWhite();
|
||||||
|
if (_pos != _json.Length) throw Error("Unexpected trailing JSON");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonValue ParseValue()
|
||||||
|
{
|
||||||
|
SkipWhite();
|
||||||
|
if (_pos >= _json.Length) throw Error("Unexpected end of JSON");
|
||||||
|
char c = _json[_pos];
|
||||||
|
if (c == '{') return ParseObjectValue();
|
||||||
|
if (c == '[') return ParseArrayValue();
|
||||||
|
if (c == '"') return new JsonString(ParseString());
|
||||||
|
if (c == '-' || char.IsDigit(c)) return ParseNumber();
|
||||||
|
if (Match("true")) return new JsonBool(true);
|
||||||
|
if (Match("false")) return new JsonBool(false);
|
||||||
|
if (Match("null")) return new JsonNull();
|
||||||
|
throw Error("Unexpected JSON value");
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject ParseObjectValue()
|
||||||
|
{
|
||||||
|
Expect('{');
|
||||||
|
var obj = new JsonObject();
|
||||||
|
SkipWhite();
|
||||||
|
if (TryConsume('}')) return obj;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SkipWhite();
|
||||||
|
string key = ParseString();
|
||||||
|
SkipWhite();
|
||||||
|
Expect(':');
|
||||||
|
obj.Add(key, ParseValue());
|
||||||
|
SkipWhite();
|
||||||
|
if (TryConsume('}')) return obj;
|
||||||
|
Expect(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonArray ParseArrayValue()
|
||||||
|
{
|
||||||
|
Expect('[');
|
||||||
|
var array = new JsonArray();
|
||||||
|
SkipWhite();
|
||||||
|
if (TryConsume(']')) return array;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
array.Add(ParseValue());
|
||||||
|
SkipWhite();
|
||||||
|
if (TryConsume(']')) return array;
|
||||||
|
Expect(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ParseString()
|
||||||
|
{
|
||||||
|
Expect('"');
|
||||||
|
var chars = new List<char>();
|
||||||
|
while (_pos < _json.Length)
|
||||||
|
{
|
||||||
|
char c = _json[_pos++];
|
||||||
|
if (c == '"') return new string(chars.ToArray());
|
||||||
|
if (c == '\\')
|
||||||
|
{
|
||||||
|
if (_pos >= _json.Length) throw Error("Unterminated JSON escape");
|
||||||
|
char escaped = _json[_pos++];
|
||||||
|
if (escaped == '"' || escaped == '\\' || escaped == '/') chars.Add(escaped);
|
||||||
|
else if (escaped == 'b') chars.Add('\b');
|
||||||
|
else if (escaped == 'f') chars.Add('\f');
|
||||||
|
else if (escaped == 'n') chars.Add('\n');
|
||||||
|
else if (escaped == 'r') chars.Add('\r');
|
||||||
|
else if (escaped == 't') chars.Add('\t');
|
||||||
|
else if (escaped == 'u') chars.Add(ParseUnicode());
|
||||||
|
else throw Error("Unsupported JSON escape");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
chars.Add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Error("Unterminated JSON string");
|
||||||
|
}
|
||||||
|
|
||||||
|
private char ParseUnicode()
|
||||||
|
{
|
||||||
|
if (_pos + 4 > _json.Length) throw Error("Invalid unicode escape");
|
||||||
|
string hex = _json.Substring(_pos, 4);
|
||||||
|
_pos += 4;
|
||||||
|
return (char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNumber ParseNumber()
|
||||||
|
{
|
||||||
|
int start = _pos;
|
||||||
|
if (_json[_pos] == '-') _pos++;
|
||||||
|
while (_pos < _json.Length && char.IsDigit(_json[_pos])) _pos++;
|
||||||
|
if (_pos < _json.Length && _json[_pos] == '.')
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
while (_pos < _json.Length && char.IsDigit(_json[_pos])) _pos++;
|
||||||
|
}
|
||||||
|
if (_pos < _json.Length && (_json[_pos] == 'e' || _json[_pos] == 'E'))
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
if (_pos < _json.Length && (_json[_pos] == '+' || _json[_pos] == '-')) _pos++;
|
||||||
|
while (_pos < _json.Length && char.IsDigit(_json[_pos])) _pos++;
|
||||||
|
}
|
||||||
|
string raw = _json.Substring(start, _pos - start);
|
||||||
|
return new JsonNumber(double.Parse(raw, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Match(string text)
|
||||||
|
{
|
||||||
|
if (_pos + text.Length > _json.Length) return false;
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
if (_json[_pos + i] != text[i]) return false;
|
||||||
|
_pos += text.Length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SkipWhite()
|
||||||
|
{
|
||||||
|
while (_pos < _json.Length && char.IsWhiteSpace(_json[_pos])) _pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryConsume(char expected)
|
||||||
|
{
|
||||||
|
if (_pos < _json.Length && _json[_pos] == expected)
|
||||||
|
{
|
||||||
|
_pos++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Expect(char expected)
|
||||||
|
{
|
||||||
|
if (_pos >= _json.Length || _json[_pos] != expected)
|
||||||
|
throw Error("Expected '" + expected + "'");
|
||||||
|
_pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvalidOperationException Error(string message)
|
||||||
|
{
|
||||||
|
return new InvalidOperationException(message + " at " + _pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.ActorBehavior
|
||||||
|
{
|
||||||
|
public struct BehaviorVector2
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
public float Y;
|
||||||
|
|
||||||
|
public BehaviorVector2(float x, float y)
|
||||||
|
{
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BehaviorVector3
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
public float Y;
|
||||||
|
public float Z;
|
||||||
|
|
||||||
|
public BehaviorVector3(float x, float y, float z)
|
||||||
|
{
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
Z = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BehaviorVector3 Zero => new BehaviorVector3(0f, 0f, 0f);
|
||||||
|
|
||||||
|
public static BehaviorVector3 operator +(BehaviorVector3 a, BehaviorVector3 b)
|
||||||
|
=> new BehaviorVector3(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
|
||||||
|
|
||||||
|
public static BehaviorVector3 operator -(BehaviorVector3 a, BehaviorVector3 b)
|
||||||
|
=> new BehaviorVector3(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
|
||||||
|
|
||||||
|
public static BehaviorVector3 operator *(BehaviorVector3 v, float s)
|
||||||
|
=> new BehaviorVector3(v.X * s, v.Y * s, v.Z * s);
|
||||||
|
|
||||||
|
public float MagnitudeXZ()
|
||||||
|
{
|
||||||
|
return (float)Math.Sqrt(X * X + Z * Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BehaviorVector3 NormalizedXZ()
|
||||||
|
{
|
||||||
|
float mag = MagnitudeXZ();
|
||||||
|
if (mag <= 0.00001f) return new BehaviorVector3(0f, 0f, 1f);
|
||||||
|
return new BehaviorVector3(X / mag, 0f, Z / mag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float DistanceXZ(BehaviorVector3 a, BehaviorVector3 b)
|
||||||
|
{
|
||||||
|
float dx = a.X - b.X;
|
||||||
|
float dz = a.Z - b.Z;
|
||||||
|
return (float)Math.Sqrt(dx * dx + dz * dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float DotXZ(BehaviorVector3 a, BehaviorVector3 b)
|
||||||
|
{
|
||||||
|
return a.X * b.X + a.Z * b.Z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.ActorBehavior
|
||||||
|
{
|
||||||
|
public enum BehaviorRunMode
|
||||||
|
{
|
||||||
|
ServerLogic = 0,
|
||||||
|
ClientPresentation = 1,
|
||||||
|
ClientPredict = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TimelineEntryKind
|
||||||
|
{
|
||||||
|
Animation = 0,
|
||||||
|
Effect = 1,
|
||||||
|
LogicEffect = 2,
|
||||||
|
Projectile = 3,
|
||||||
|
Buff = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TimelineTriggerKind
|
||||||
|
{
|
||||||
|
Time = 0,
|
||||||
|
OnCollision = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LogicShapeKind
|
||||||
|
{
|
||||||
|
Sector = 0,
|
||||||
|
Sphere = 1,
|
||||||
|
Ring = 2,
|
||||||
|
Line = 3,
|
||||||
|
Locked = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LogicTargetKind
|
||||||
|
{
|
||||||
|
Self = 0,
|
||||||
|
Ally = 1,
|
||||||
|
Enemy = 2,
|
||||||
|
All = 3,
|
||||||
|
Owner = 4,
|
||||||
|
HitActor = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LogicEffectOpKind
|
||||||
|
{
|
||||||
|
Attribute = 0,
|
||||||
|
Buff = 1,
|
||||||
|
Projectile = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FlyMovementKind
|
||||||
|
{
|
||||||
|
Line = 0,
|
||||||
|
Parabola = 1,
|
||||||
|
Homing = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CollisionShapeKind
|
||||||
|
{
|
||||||
|
Sphere = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BuffStackingKind
|
||||||
|
{
|
||||||
|
RefreshDuration = 0,
|
||||||
|
IgnoreIfExists = 1,
|
||||||
|
StackIndependent = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BehaviorInputKind
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Direction = 1,
|
||||||
|
TargetPosition = 2,
|
||||||
|
TargetActor = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BehaviorStopReason
|
||||||
|
{
|
||||||
|
Completed = 0,
|
||||||
|
Stunned = 1,
|
||||||
|
Knockback = 2,
|
||||||
|
Death = 3,
|
||||||
|
ForcedSwitch = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BehaviorEventKind
|
||||||
|
{
|
||||||
|
BehaviorStarted = 0,
|
||||||
|
BehaviorStopped = 1,
|
||||||
|
TimelineAnimation = 2,
|
||||||
|
TimelineEffect = 3,
|
||||||
|
ProjectileSpawned = 4,
|
||||||
|
ProjectileMoved = 5,
|
||||||
|
ProjectileHit = 6,
|
||||||
|
BuffAdded = 7,
|
||||||
|
BuffTicked = 8,
|
||||||
|
BuffRemoved = 9,
|
||||||
|
LogicEffectApplied = 10,
|
||||||
|
AttributeEffectRequested = 11
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BehaviorWorldOptions
|
||||||
|
{
|
||||||
|
public BehaviorRunMode Mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BehaviorInput
|
||||||
|
{
|
||||||
|
public BehaviorInputKind Kind;
|
||||||
|
public BehaviorVector3 Direction;
|
||||||
|
public BehaviorVector3 TargetPosition;
|
||||||
|
public int TargetActorId;
|
||||||
|
|
||||||
|
public static readonly BehaviorInput None = new BehaviorInput { Kind = BehaviorInputKind.None };
|
||||||
|
|
||||||
|
public static BehaviorInput FromDirection(float x, float z)
|
||||||
|
{
|
||||||
|
return new BehaviorInput { Kind = BehaviorInputKind.Direction, Direction = new BehaviorVector3(x, 0f, z).NormalizedXZ() };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BehaviorInput FromTargetPosition(float x, float y, float z)
|
||||||
|
{
|
||||||
|
return new BehaviorInput { Kind = BehaviorInputKind.TargetPosition, TargetPosition = new BehaviorVector3(x, y, z) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BehaviorInput FromTargetActor(int actorId)
|
||||||
|
{
|
||||||
|
return new BehaviorInput { Kind = BehaviorInputKind.TargetActor, TargetActorId = actorId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BehaviorStartResult
|
||||||
|
{
|
||||||
|
public bool Success;
|
||||||
|
public string Error;
|
||||||
|
|
||||||
|
public static BehaviorStartResult Ok()
|
||||||
|
{
|
||||||
|
return new BehaviorStartResult { Success = true, Error = string.Empty };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BehaviorStartResult Fail(string error)
|
||||||
|
{
|
||||||
|
return new BehaviorStartResult { Success = false, Error = error ?? string.Empty };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BehaviorActorState
|
||||||
|
{
|
||||||
|
public int ActorId;
|
||||||
|
public string CatalogType;
|
||||||
|
public int TeamId;
|
||||||
|
public BehaviorVector3 Position;
|
||||||
|
public BehaviorVector3 Forward;
|
||||||
|
public float Radius;
|
||||||
|
public bool Alive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BehaviorEvent
|
||||||
|
{
|
||||||
|
public BehaviorEventKind Kind;
|
||||||
|
public int ActorId;
|
||||||
|
public int SourceActorId;
|
||||||
|
public int TargetActorId;
|
||||||
|
public string BehaviorId;
|
||||||
|
public string FlyObjectId;
|
||||||
|
public string BuffId;
|
||||||
|
public string LogicEffectId;
|
||||||
|
public string Animation;
|
||||||
|
public string Prefab;
|
||||||
|
public string Attribute;
|
||||||
|
public float Value;
|
||||||
|
public BehaviorVector3 Position;
|
||||||
|
public BehaviorStopReason StopReason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.ActorBehavior
|
||||||
|
{
|
||||||
|
public sealed class BehaviorWorld
|
||||||
|
{
|
||||||
|
private readonly BehaviorWorldOptions _options;
|
||||||
|
private readonly Dictionary<string, BehaviorConfigCatalog> _catalogs = new Dictionary<string, BehaviorConfigCatalog>(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<int, BehaviorActorState> _actors = new Dictionary<int, BehaviorActorState>();
|
||||||
|
private readonly Dictionary<int, ActiveBehavior> _activeBehaviors = new Dictionary<int, ActiveBehavior>();
|
||||||
|
private readonly List<ActiveProjectile> _projectiles = new List<ActiveProjectile>();
|
||||||
|
private readonly Dictionary<int, List<ActiveBuff>> _buffs = new Dictionary<int, List<ActiveBuff>>();
|
||||||
|
private readonly List<BehaviorEvent> _events = new List<BehaviorEvent>();
|
||||||
|
private int _nextProjectileId = 1;
|
||||||
|
|
||||||
|
public BehaviorWorld(BehaviorWorldOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ActiveProjectileCount => _projectiles.Count;
|
||||||
|
|
||||||
|
public void RegisterCatalog(BehaviorConfigCatalog catalog)
|
||||||
|
{
|
||||||
|
if (catalog == null) throw new ArgumentNullException(nameof(catalog));
|
||||||
|
_catalogs[catalog.Type] = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddActor(BehaviorActorState actor)
|
||||||
|
{
|
||||||
|
if (actor == null) throw new ArgumentNullException(nameof(actor));
|
||||||
|
_actors[actor.ActorId] = actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool SwitchActorCatalog(int actorId, string catalogType)
|
||||||
|
{
|
||||||
|
if (!_actors.TryGetValue(actorId, out BehaviorActorState actor)) return false;
|
||||||
|
if (!_catalogs.ContainsKey(catalogType)) return false;
|
||||||
|
actor.CatalogType = catalogType;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BehaviorStartResult TryStartBehavior(int actorId, string behaviorId, BehaviorInput input)
|
||||||
|
{
|
||||||
|
if (!_actors.TryGetValue(actorId, out BehaviorActorState actor))
|
||||||
|
return BehaviorStartResult.Fail("actor not found");
|
||||||
|
if (!_catalogs.TryGetValue(actor.CatalogType, out BehaviorConfigCatalog catalog))
|
||||||
|
return BehaviorStartResult.Fail("catalog not found");
|
||||||
|
if (!catalog.Behaviors.TryGetValue(behaviorId, out BehaviorSpec spec))
|
||||||
|
return BehaviorStartResult.Fail("behavior not found");
|
||||||
|
if (_activeBehaviors.ContainsKey(actorId))
|
||||||
|
return BehaviorStartResult.Fail("actor already has active behavior");
|
||||||
|
|
||||||
|
var active = new ActiveBehavior
|
||||||
|
{
|
||||||
|
ActorId = actorId,
|
||||||
|
Catalog = catalog,
|
||||||
|
Spec = spec,
|
||||||
|
Input = input,
|
||||||
|
Elapsed = 0f
|
||||||
|
};
|
||||||
|
_activeBehaviors[actorId] = active;
|
||||||
|
_events.Add(new BehaviorEvent { Kind = BehaviorEventKind.BehaviorStarted, ActorId = actorId, BehaviorId = spec.Id });
|
||||||
|
FireTimeline(active, -0.00001f, 0f);
|
||||||
|
return BehaviorStartResult.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool StopBehavior(int actorId, BehaviorStopReason reason)
|
||||||
|
{
|
||||||
|
if (!_activeBehaviors.TryGetValue(actorId, out ActiveBehavior active)) return false;
|
||||||
|
if (!active.Spec.CanBeInterrupted && reason != BehaviorStopReason.Death && reason != BehaviorStopReason.Completed) return false;
|
||||||
|
_activeBehaviors.Remove(actorId);
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.BehaviorStopped,
|
||||||
|
ActorId = actorId,
|
||||||
|
BehaviorId = active.Spec.Id,
|
||||||
|
StopReason = reason
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(float deltaTime)
|
||||||
|
{
|
||||||
|
if (deltaTime <= 0f) return;
|
||||||
|
TickProjectiles(deltaTime);
|
||||||
|
TickBuffs(deltaTime);
|
||||||
|
|
||||||
|
var completed = new List<int>();
|
||||||
|
foreach (ActiveBehavior active in new List<ActiveBehavior>(_activeBehaviors.Values))
|
||||||
|
{
|
||||||
|
float previous = active.Elapsed;
|
||||||
|
active.Elapsed += deltaTime;
|
||||||
|
FireTimeline(active, previous, active.Elapsed);
|
||||||
|
if (active.Elapsed >= active.Spec.Duration)
|
||||||
|
{
|
||||||
|
completed.Add(active.ActorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < completed.Count; i++)
|
||||||
|
{
|
||||||
|
StopBehavior(completed[i], BehaviorStopReason.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<BehaviorEvent> DrainEvents()
|
||||||
|
{
|
||||||
|
var drained = _events.ToArray();
|
||||||
|
_events.Clear();
|
||||||
|
return drained;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasActiveBehavior(int actorId)
|
||||||
|
{
|
||||||
|
return _activeBehaviors.ContainsKey(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ActiveBuffCount(int actorId)
|
||||||
|
{
|
||||||
|
return _buffs.TryGetValue(actorId, out List<ActiveBuff> buffs) ? buffs.Count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId)
|
||||||
|
{
|
||||||
|
if (!_actors.ContainsKey(targetActorId)) return false;
|
||||||
|
if (!_catalogs.TryGetValue(catalogType, out BehaviorConfigCatalog catalog)) return false;
|
||||||
|
if (!catalog.Buffs.TryGetValue(buffId, out BuffSpec spec)) return false;
|
||||||
|
|
||||||
|
if (!_buffs.TryGetValue(targetActorId, out List<ActiveBuff> activeBuffs))
|
||||||
|
{
|
||||||
|
activeBuffs = new List<ActiveBuff>();
|
||||||
|
_buffs[targetActorId] = activeBuffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveBuff existing = null;
|
||||||
|
for (int i = 0; i < activeBuffs.Count; i++)
|
||||||
|
{
|
||||||
|
if (activeBuffs[i].Spec.Id == buffId)
|
||||||
|
{
|
||||||
|
existing = activeBuffs[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing != null && spec.Stacking == BuffStackingKind.IgnoreIfExists)
|
||||||
|
return true;
|
||||||
|
if (existing != null && spec.Stacking == BuffStackingKind.RefreshDuration)
|
||||||
|
{
|
||||||
|
existing.Elapsed = 0f;
|
||||||
|
existing.NextTickTime = spec.TickInterval > 0f ? spec.TickInterval : spec.Duration;
|
||||||
|
existing.TickCount = 0;
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.BuffAdded,
|
||||||
|
ActorId = sourceActorId,
|
||||||
|
SourceActorId = sourceActorId,
|
||||||
|
TargetActorId = targetActorId,
|
||||||
|
BuffId = spec.Id
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBuffs.Add(new ActiveBuff
|
||||||
|
{
|
||||||
|
SourceActorId = sourceActorId,
|
||||||
|
TargetActorId = targetActorId,
|
||||||
|
Catalog = catalog,
|
||||||
|
Spec = spec,
|
||||||
|
Elapsed = 0f,
|
||||||
|
NextTickTime = spec.TickInterval > 0f ? spec.TickInterval : spec.Duration,
|
||||||
|
TickCount = 0
|
||||||
|
});
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.BuffAdded,
|
||||||
|
ActorId = sourceActorId,
|
||||||
|
SourceActorId = sourceActorId,
|
||||||
|
TargetActorId = targetActorId,
|
||||||
|
BuffId = spec.Id
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireTimeline(ActiveBehavior active, float previousElapsed, float currentElapsed)
|
||||||
|
{
|
||||||
|
List<TimelineEntrySpec> timeline = active.Spec.Timeline;
|
||||||
|
for (int i = 0; i < timeline.Count; i++)
|
||||||
|
{
|
||||||
|
if (active.FiredTimeline.Contains(i)) continue;
|
||||||
|
TimelineEntrySpec entry = timeline[i];
|
||||||
|
if (entry.Trigger != TimelineTriggerKind.Time) continue;
|
||||||
|
if (entry.Time > currentElapsed || entry.Time <= previousElapsed) continue;
|
||||||
|
active.FiredTimeline.Add(i);
|
||||||
|
FireTimelineEntry(active, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireTimelineEntry(ActiveBehavior active, TimelineEntrySpec entry)
|
||||||
|
{
|
||||||
|
if (entry.Kind == TimelineEntryKind.Animation)
|
||||||
|
{
|
||||||
|
if (_options.Mode == BehaviorRunMode.ClientPresentation || _options.Mode == BehaviorRunMode.ClientPredict)
|
||||||
|
{
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.TimelineAnimation,
|
||||||
|
ActorId = active.ActorId,
|
||||||
|
BehaviorId = active.Spec.Id,
|
||||||
|
Animation = entry.Animation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Kind == TimelineEntryKind.Effect)
|
||||||
|
{
|
||||||
|
if (_options.Mode == BehaviorRunMode.ClientPresentation || _options.Mode == BehaviorRunMode.ClientPredict)
|
||||||
|
{
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.TimelineEffect,
|
||||||
|
ActorId = active.ActorId,
|
||||||
|
BehaviorId = active.Spec.Id,
|
||||||
|
Prefab = entry.Prefab
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Kind == TimelineEntryKind.LogicEffect && _options.Mode == BehaviorRunMode.ServerLogic)
|
||||||
|
{
|
||||||
|
LogicEffectSpec effect = FindLogicEffect(active.Spec.LogicEffects, entry.EffectId);
|
||||||
|
if (effect != null) ApplyLogicEffect(active.ActorId, active.Catalog, effect, active.Input, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Kind == TimelineEntryKind.Buff)
|
||||||
|
{
|
||||||
|
int targetActorId = active.Input.Kind == BehaviorInputKind.TargetActor ? active.Input.TargetActorId : active.ActorId;
|
||||||
|
ApplyBuff(targetActorId, active.Catalog.Type, entry.BuffId, active.ActorId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Kind == TimelineEntryKind.Projectile)
|
||||||
|
{
|
||||||
|
SpawnProjectile(active.ActorId, active.Catalog, entry.FlyObjectId, active.Input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLogicEffect(int sourceActorId, BehaviorConfigCatalog catalog, LogicEffectSpec effect, BehaviorInput input, int hitActorId)
|
||||||
|
{
|
||||||
|
ApplyLogicEffect(sourceActorId, sourceActorId, catalog, effect, input, hitActorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLogicEffect(int eventSourceActorId, int originActorId, BehaviorConfigCatalog catalog, LogicEffectSpec effect, BehaviorInput input, int hitActorId)
|
||||||
|
{
|
||||||
|
foreach (int targetActorId in SelectTargets(originActorId, effect, input, hitActorId))
|
||||||
|
{
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.LogicEffectApplied,
|
||||||
|
ActorId = eventSourceActorId,
|
||||||
|
SourceActorId = eventSourceActorId,
|
||||||
|
TargetActorId = targetActorId,
|
||||||
|
LogicEffectId = effect.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
for (int i = 0; i < effect.Effects.Count; i++)
|
||||||
|
{
|
||||||
|
LogicEffectOpSpec op = effect.Effects[i];
|
||||||
|
if (op.Type == LogicEffectOpKind.Attribute)
|
||||||
|
{
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.AttributeEffectRequested,
|
||||||
|
ActorId = eventSourceActorId,
|
||||||
|
SourceActorId = eventSourceActorId,
|
||||||
|
TargetActorId = targetActorId,
|
||||||
|
LogicEffectId = effect.Id,
|
||||||
|
Attribute = op.Attribute,
|
||||||
|
Value = op.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (op.Type == LogicEffectOpKind.Projectile)
|
||||||
|
{
|
||||||
|
SpawnProjectile(originActorId, catalog, op.FlyObjectId, input);
|
||||||
|
}
|
||||||
|
else if (op.Type == LogicEffectOpKind.Buff)
|
||||||
|
{
|
||||||
|
ApplyBuff(targetActorId, catalog.Type, op.BuffId, eventSourceActorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TickBuffs(float deltaTime)
|
||||||
|
{
|
||||||
|
var actorIds = new List<int>(_buffs.Keys);
|
||||||
|
for (int actorIndex = 0; actorIndex < actorIds.Count; actorIndex++)
|
||||||
|
{
|
||||||
|
int actorId = actorIds[actorIndex];
|
||||||
|
List<ActiveBuff> activeBuffs = _buffs[actorId];
|
||||||
|
var expired = new List<ActiveBuff>();
|
||||||
|
for (int i = 0; i < activeBuffs.Count; i++)
|
||||||
|
{
|
||||||
|
ActiveBuff buff = activeBuffs[i];
|
||||||
|
buff.Elapsed += deltaTime;
|
||||||
|
while (buff.Spec.TickInterval > 0f
|
||||||
|
&& buff.Elapsed + 0.00001f >= buff.NextTickTime
|
||||||
|
&& (buff.Spec.RepeatCount <= 0 || buff.TickCount < buff.Spec.RepeatCount))
|
||||||
|
{
|
||||||
|
buff.TickCount++;
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.BuffTicked,
|
||||||
|
ActorId = buff.SourceActorId,
|
||||||
|
SourceActorId = buff.SourceActorId,
|
||||||
|
TargetActorId = buff.TargetActorId,
|
||||||
|
BuffId = buff.Spec.Id
|
||||||
|
});
|
||||||
|
if (_options.Mode == BehaviorRunMode.ServerLogic)
|
||||||
|
{
|
||||||
|
for (int e = 0; e < buff.Spec.LogicEffects.Count; e++)
|
||||||
|
ApplyLogicEffect(buff.SourceActorId, buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId);
|
||||||
|
}
|
||||||
|
buff.NextTickTime += buff.Spec.TickInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool repeatReached = buff.Spec.RepeatCount > 0 && buff.TickCount >= buff.Spec.RepeatCount;
|
||||||
|
bool durationReached = buff.Spec.Duration > 0f && buff.Elapsed >= buff.Spec.Duration;
|
||||||
|
if (repeatReached || durationReached)
|
||||||
|
{
|
||||||
|
expired.Add(buff);
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.BuffRemoved,
|
||||||
|
ActorId = buff.SourceActorId,
|
||||||
|
SourceActorId = buff.SourceActorId,
|
||||||
|
TargetActorId = buff.TargetActorId,
|
||||||
|
BuffId = buff.Spec.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < expired.Count; i++)
|
||||||
|
activeBuffs.Remove(expired[i]);
|
||||||
|
if (activeBuffs.Count == 0)
|
||||||
|
_buffs.Remove(actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SpawnProjectile(int sourceActorId, BehaviorConfigCatalog catalog, string flyObjectId, BehaviorInput input)
|
||||||
|
{
|
||||||
|
if (!_actors.TryGetValue(sourceActorId, out BehaviorActorState source)) return;
|
||||||
|
if (!catalog.FlyObjects.TryGetValue(flyObjectId, out FlyObjectSpec spec)) return;
|
||||||
|
|
||||||
|
int targetActorId = input.Kind == BehaviorInputKind.TargetActor ? input.TargetActorId : 0;
|
||||||
|
BehaviorVector3 direction = ResolveProjectileDirection(source, input, targetActorId);
|
||||||
|
var projectile = new ActiveProjectile
|
||||||
|
{
|
||||||
|
RuntimeId = _nextProjectileId++,
|
||||||
|
SourceActorId = sourceActorId,
|
||||||
|
Catalog = catalog,
|
||||||
|
Spec = spec,
|
||||||
|
Position = source.Position,
|
||||||
|
Direction = direction,
|
||||||
|
TargetActorId = targetActorId,
|
||||||
|
Age = 0f,
|
||||||
|
VerticalVelocity = spec.Movement.Speed * 0.5f
|
||||||
|
};
|
||||||
|
_projectiles.Add(projectile);
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.ProjectileSpawned,
|
||||||
|
ActorId = sourceActorId,
|
||||||
|
SourceActorId = sourceActorId,
|
||||||
|
FlyObjectId = spec.Id,
|
||||||
|
Position = projectile.Position
|
||||||
|
});
|
||||||
|
FireProjectileTimeline(projectile, -0.00001f, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BehaviorVector3 ResolveProjectileDirection(BehaviorActorState source, BehaviorInput input, int targetActorId)
|
||||||
|
{
|
||||||
|
if (input.Kind == BehaviorInputKind.Direction)
|
||||||
|
return input.Direction.NormalizedXZ();
|
||||||
|
if (input.Kind == BehaviorInputKind.TargetPosition)
|
||||||
|
return (input.TargetPosition - source.Position).NormalizedXZ();
|
||||||
|
if (targetActorId != 0 && _actors.TryGetValue(targetActorId, out BehaviorActorState target))
|
||||||
|
return (target.Position - source.Position).NormalizedXZ();
|
||||||
|
return source.Forward.NormalizedXZ();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TickProjectiles(float deltaTime)
|
||||||
|
{
|
||||||
|
var expired = new HashSet<ActiveProjectile>();
|
||||||
|
foreach (ActiveProjectile projectile in _projectiles)
|
||||||
|
{
|
||||||
|
float previousAge = projectile.Age;
|
||||||
|
projectile.Age += deltaTime;
|
||||||
|
MoveProjectile(projectile, deltaTime);
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.ProjectileMoved,
|
||||||
|
ActorId = projectile.SourceActorId,
|
||||||
|
SourceActorId = projectile.SourceActorId,
|
||||||
|
FlyObjectId = projectile.Spec.Id,
|
||||||
|
Position = projectile.Position
|
||||||
|
});
|
||||||
|
FireProjectileTimeline(projectile, previousAge, projectile.Age);
|
||||||
|
|
||||||
|
if (_options.Mode == BehaviorRunMode.ServerLogic)
|
||||||
|
CheckProjectileCollision(projectile, expired);
|
||||||
|
|
||||||
|
if (projectile.Age >= projectile.Spec.Duration)
|
||||||
|
expired.Add(projectile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired.Count == 0) return;
|
||||||
|
_projectiles.RemoveAll(p => expired.Contains(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveProjectile(ActiveProjectile projectile, float deltaTime)
|
||||||
|
{
|
||||||
|
FlyMovementSpec movement = projectile.Spec.Movement;
|
||||||
|
BehaviorVector3 direction = projectile.Direction.NormalizedXZ();
|
||||||
|
if (movement.Kind == FlyMovementKind.Homing
|
||||||
|
&& projectile.TargetActorId != 0
|
||||||
|
&& _actors.TryGetValue(projectile.TargetActorId, out BehaviorActorState target))
|
||||||
|
{
|
||||||
|
direction = (target.Position - projectile.Position).NormalizedXZ();
|
||||||
|
projectile.Direction = direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
projectile.Position += direction * (movement.Speed * deltaTime);
|
||||||
|
if (movement.Kind == FlyMovementKind.Parabola)
|
||||||
|
{
|
||||||
|
projectile.Position = new BehaviorVector3(
|
||||||
|
projectile.Position.X,
|
||||||
|
projectile.Position.Y + projectile.VerticalVelocity * deltaTime - 0.5f * movement.Gravity * deltaTime * deltaTime,
|
||||||
|
projectile.Position.Z);
|
||||||
|
projectile.VerticalVelocity -= movement.Gravity * deltaTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckProjectileCollision(ActiveProjectile projectile, HashSet<ActiveProjectile> expired)
|
||||||
|
{
|
||||||
|
if (!_actors.TryGetValue(projectile.SourceActorId, out BehaviorActorState source)) return;
|
||||||
|
foreach (BehaviorActorState actor in _actors.Values)
|
||||||
|
{
|
||||||
|
if (!actor.Alive) continue;
|
||||||
|
if (actor.ActorId == projectile.SourceActorId) continue;
|
||||||
|
if (actor.TeamId == source.TeamId) continue;
|
||||||
|
if (projectile.HitActorIds.Contains(actor.ActorId)) continue;
|
||||||
|
float distance = BehaviorVector3.DistanceXZ(projectile.Position, actor.Position);
|
||||||
|
if (distance > projectile.Spec.Collision.Radius + actor.Radius) continue;
|
||||||
|
|
||||||
|
projectile.HitCount++;
|
||||||
|
projectile.HitActorIds.Add(actor.ActorId);
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.ProjectileHit,
|
||||||
|
ActorId = projectile.SourceActorId,
|
||||||
|
SourceActorId = projectile.SourceActorId,
|
||||||
|
TargetActorId = actor.ActorId,
|
||||||
|
FlyObjectId = projectile.Spec.Id,
|
||||||
|
Position = projectile.Position
|
||||||
|
});
|
||||||
|
FireProjectileCollisionLogic(projectile, actor.ActorId);
|
||||||
|
|
||||||
|
if (projectile.Spec.Collision.HitLimit > 0 && projectile.HitCount >= projectile.Spec.Collision.HitLimit)
|
||||||
|
{
|
||||||
|
expired.Add(projectile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireProjectileTimeline(ActiveProjectile projectile, float previousElapsed, float currentElapsed)
|
||||||
|
{
|
||||||
|
List<TimelineEntrySpec> timeline = projectile.Spec.Timeline;
|
||||||
|
for (int i = 0; i < timeline.Count; i++)
|
||||||
|
{
|
||||||
|
if (projectile.FiredTimeline.Contains(i)) continue;
|
||||||
|
TimelineEntrySpec entry = timeline[i];
|
||||||
|
if (entry.Trigger != TimelineTriggerKind.Time) continue;
|
||||||
|
if (entry.Time > currentElapsed || entry.Time <= previousElapsed) continue;
|
||||||
|
projectile.FiredTimeline.Add(i);
|
||||||
|
FireProjectileTimelineEntry(projectile, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireProjectileTimelineEntry(ActiveProjectile projectile, TimelineEntrySpec entry)
|
||||||
|
{
|
||||||
|
if (entry.Kind == TimelineEntryKind.Effect)
|
||||||
|
{
|
||||||
|
if (_options.Mode == BehaviorRunMode.ClientPresentation || _options.Mode == BehaviorRunMode.ClientPredict)
|
||||||
|
{
|
||||||
|
_events.Add(new BehaviorEvent
|
||||||
|
{
|
||||||
|
Kind = BehaviorEventKind.TimelineEffect,
|
||||||
|
ActorId = projectile.SourceActorId,
|
||||||
|
SourceActorId = projectile.SourceActorId,
|
||||||
|
FlyObjectId = projectile.Spec.Id,
|
||||||
|
Prefab = entry.Prefab,
|
||||||
|
Position = projectile.Position
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Kind == TimelineEntryKind.LogicEffect && _options.Mode == BehaviorRunMode.ServerLogic)
|
||||||
|
{
|
||||||
|
LogicEffectSpec effect = FindLogicEffect(projectile.Spec.LogicEffects, entry.EffectId);
|
||||||
|
if (effect != null)
|
||||||
|
ApplyLogicEffect(projectile.SourceActorId, projectile.Catalog, effect, BehaviorInput.FromTargetActor(projectile.TargetActorId), projectile.TargetActorId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Kind == TimelineEntryKind.Buff)
|
||||||
|
{
|
||||||
|
int targetActorId = projectile.TargetActorId != 0 ? projectile.TargetActorId : projectile.SourceActorId;
|
||||||
|
ApplyBuff(targetActorId, projectile.Catalog.Type, entry.BuffId, projectile.SourceActorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireProjectileCollisionLogic(ActiveProjectile projectile, int hitActorId)
|
||||||
|
{
|
||||||
|
List<TimelineEntrySpec> timeline = projectile.Spec.Timeline;
|
||||||
|
for (int i = 0; i < timeline.Count; i++)
|
||||||
|
{
|
||||||
|
TimelineEntrySpec entry = timeline[i];
|
||||||
|
if (entry.Kind != TimelineEntryKind.LogicEffect || entry.Trigger != TimelineTriggerKind.OnCollision)
|
||||||
|
continue;
|
||||||
|
LogicEffectSpec effect = FindLogicEffect(projectile.Spec.LogicEffects, entry.EffectId);
|
||||||
|
if (effect != null)
|
||||||
|
ApplyLogicEffect(projectile.SourceActorId, projectile.Catalog, effect, BehaviorInput.FromTargetActor(hitActorId), hitActorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<int> SelectTargets(int sourceActorId, LogicEffectSpec effect, BehaviorInput input, int hitActorId)
|
||||||
|
{
|
||||||
|
if (!_actors.TryGetValue(sourceActorId, out BehaviorActorState source))
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
if (effect.Target == LogicTargetKind.HitActor)
|
||||||
|
{
|
||||||
|
int targetId = hitActorId != 0 ? hitActorId : input.TargetActorId;
|
||||||
|
if (targetId != 0 && _actors.ContainsKey(targetId)) yield return targetId;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (BehaviorActorState actor in _actors.Values)
|
||||||
|
{
|
||||||
|
if (!actor.Alive) continue;
|
||||||
|
if (!PassesTargetFilter(source, actor, effect.Target)) continue;
|
||||||
|
if (IsInsideShape(source, actor, effect, ResolveEffectDirection(source, input)))
|
||||||
|
yield return actor.ActorId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PassesTargetFilter(BehaviorActorState source, BehaviorActorState target, LogicTargetKind filter)
|
||||||
|
{
|
||||||
|
if (filter == LogicTargetKind.Self || filter == LogicTargetKind.Owner)
|
||||||
|
return target.ActorId == source.ActorId;
|
||||||
|
if (filter == LogicTargetKind.Ally)
|
||||||
|
return target.ActorId != source.ActorId && target.TeamId == source.TeamId;
|
||||||
|
if (filter == LogicTargetKind.Enemy)
|
||||||
|
return target.TeamId != source.TeamId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BehaviorVector3 ResolveEffectDirection(BehaviorActorState source, BehaviorInput input)
|
||||||
|
{
|
||||||
|
if (input.Kind == BehaviorInputKind.Direction)
|
||||||
|
return input.Direction.NormalizedXZ();
|
||||||
|
if (input.Kind == BehaviorInputKind.TargetPosition)
|
||||||
|
return (input.TargetPosition - source.Position).NormalizedXZ();
|
||||||
|
if (input.Kind == BehaviorInputKind.TargetActor
|
||||||
|
&& input.TargetActorId != 0
|
||||||
|
&& _actors.TryGetValue(input.TargetActorId, out BehaviorActorState target))
|
||||||
|
return (target.Position - source.Position).NormalizedXZ();
|
||||||
|
return source.Forward.NormalizedXZ();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInsideShape(BehaviorActorState source, BehaviorActorState target, LogicEffectSpec effect, BehaviorVector3 direction)
|
||||||
|
{
|
||||||
|
float distance = BehaviorVector3.DistanceXZ(source.Position, target.Position);
|
||||||
|
if (effect.Shape == LogicShapeKind.Sphere)
|
||||||
|
return distance <= effect.Radius + target.Radius;
|
||||||
|
if (effect.Shape == LogicShapeKind.Ring)
|
||||||
|
return distance >= effect.MinRadius && distance <= effect.Radius + target.Radius;
|
||||||
|
if (effect.Shape == LogicShapeKind.Sector)
|
||||||
|
{
|
||||||
|
if (distance > effect.Radius + target.Radius) return false;
|
||||||
|
BehaviorVector3 toTarget = (target.Position - source.Position).NormalizedXZ();
|
||||||
|
BehaviorVector3 forward = direction.NormalizedXZ();
|
||||||
|
float dot = Clamp(BehaviorVector3.DotXZ(forward, toTarget), -1f, 1f);
|
||||||
|
float angle = (float)(Math.Acos(dot) * 180.0 / Math.PI);
|
||||||
|
return angle <= effect.Angle * 0.5f;
|
||||||
|
}
|
||||||
|
if (effect.Shape == LogicShapeKind.Line)
|
||||||
|
{
|
||||||
|
float width = effect.Width > 0f ? effect.Width : target.Radius;
|
||||||
|
float dist = DistancePointToSegmentXZ(target.Position, source.Position, source.Position + direction.NormalizedXZ() * effect.Radius);
|
||||||
|
return dist <= width + target.Radius;
|
||||||
|
}
|
||||||
|
return target.ActorId == source.ActorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float DistancePointToSegmentXZ(BehaviorVector3 point, BehaviorVector3 start, BehaviorVector3 end)
|
||||||
|
{
|
||||||
|
BehaviorVector3 ab = end - start;
|
||||||
|
BehaviorVector3 ap = point - start;
|
||||||
|
float abLenSq = ab.X * ab.X + ab.Z * ab.Z;
|
||||||
|
if (abLenSq <= 0.00001f) return BehaviorVector3.DistanceXZ(point, start);
|
||||||
|
float t = Clamp((ap.X * ab.X + ap.Z * ab.Z) / abLenSq, 0f, 1f);
|
||||||
|
BehaviorVector3 nearest = start + ab * t;
|
||||||
|
return BehaviorVector3.DistanceXZ(point, nearest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Clamp(float value, float min, float max)
|
||||||
|
{
|
||||||
|
if (value < min) return min;
|
||||||
|
return value > max ? max : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogicEffectSpec FindLogicEffect(List<LogicEffectSpec> effects, string effectId)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < effects.Count; i++)
|
||||||
|
if (effects[i].Id == effectId) return effects[i];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ActiveBehavior
|
||||||
|
{
|
||||||
|
public int ActorId;
|
||||||
|
public BehaviorConfigCatalog Catalog;
|
||||||
|
public BehaviorSpec Spec;
|
||||||
|
public BehaviorInput Input;
|
||||||
|
public float Elapsed;
|
||||||
|
public readonly HashSet<int> FiredTimeline = new HashSet<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ActiveProjectile
|
||||||
|
{
|
||||||
|
public int RuntimeId;
|
||||||
|
public int SourceActorId;
|
||||||
|
public BehaviorConfigCatalog Catalog;
|
||||||
|
public FlyObjectSpec Spec;
|
||||||
|
public BehaviorVector3 Position;
|
||||||
|
public BehaviorVector3 Direction;
|
||||||
|
public int TargetActorId;
|
||||||
|
public float Age;
|
||||||
|
public float VerticalVelocity;
|
||||||
|
public int HitCount;
|
||||||
|
public readonly HashSet<int> FiredTimeline = new HashSet<int>();
|
||||||
|
public readonly HashSet<int> HitActorIds = new HashSet<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ActiveBuff
|
||||||
|
{
|
||||||
|
public int SourceActorId;
|
||||||
|
public int TargetActorId;
|
||||||
|
public BehaviorConfigCatalog Catalog;
|
||||||
|
public BuffSpec Spec;
|
||||||
|
public float Elapsed;
|
||||||
|
public float NextTickTime;
|
||||||
|
public int TickCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"type": "fighter",
|
||||||
|
"version": 1,
|
||||||
|
"buffs": [
|
||||||
|
{
|
||||||
|
"id": "slow_01",
|
||||||
|
"duration": 3.0,
|
||||||
|
"tickInterval": 1.0,
|
||||||
|
"repeatCount": 3,
|
||||||
|
"stacking": "RefreshDuration",
|
||||||
|
"logicEffects": [
|
||||||
|
{
|
||||||
|
"id": "slow_tick",
|
||||||
|
"shape": "Locked",
|
||||||
|
"target": "Owner",
|
||||||
|
"effects": [
|
||||||
|
{ "type": "Attribute", "attribute": "moveSpeed", "value": -0.2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"type": "fighter",
|
||||||
|
"version": 1,
|
||||||
|
"flyObjects": [
|
||||||
|
{
|
||||||
|
"id": "blade_wave",
|
||||||
|
"duration": 1.5,
|
||||||
|
"movement": { "kind": "Line", "speed": 6.0 },
|
||||||
|
"collision": { "shape": "Sphere", "radius": 0.4, "hitLimit": 1 },
|
||||||
|
"timeline": [
|
||||||
|
{ "time": 0.0, "kind": "Effect", "prefab": "Assets/Game/Art/Effect/blade_wave.prefab", "duration": 1.5 },
|
||||||
|
{ "time": 0.0, "kind": "LogicEffect", "effectId": "blade_wave_hit", "trigger": "OnCollision" }
|
||||||
|
],
|
||||||
|
"logicEffects": [
|
||||||
|
{
|
||||||
|
"id": "blade_wave_hit",
|
||||||
|
"shape": "Locked",
|
||||||
|
"target": "HitActor",
|
||||||
|
"effects": [
|
||||||
|
{ "type": "Attribute", "attribute": "hp", "value": -20 },
|
||||||
|
{ "type": "Buff", "buffId": "slow_01" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"type": "fighter",
|
||||||
|
"version": 1,
|
||||||
|
"behaviors": [
|
||||||
|
{
|
||||||
|
"id": "slash",
|
||||||
|
"duration": 0.8,
|
||||||
|
"canBeInterrupted": true,
|
||||||
|
"input": "DirectionOrTarget",
|
||||||
|
"timeline": [
|
||||||
|
{ "time": 0.0, "kind": "Animation", "animation": "slash_01", "duration": 0.8 },
|
||||||
|
{ "time": 0.12, "kind": "Effect", "prefab": "Assets/Game/Art/Effect/slash.prefab", "duration": 0.5 },
|
||||||
|
{ "time": 0.25, "kind": "LogicEffect", "effectId": "slash_hit" },
|
||||||
|
{ "time": 0.32, "kind": "Projectile", "flyObjectId": "blade_wave" }
|
||||||
|
],
|
||||||
|
"logicEffects": [
|
||||||
|
{
|
||||||
|
"id": "slash_hit",
|
||||||
|
"shape": "Sector",
|
||||||
|
"radius": 2.4,
|
||||||
|
"angle": 90,
|
||||||
|
"target": "Enemy",
|
||||||
|
"effects": [
|
||||||
|
{ "type": "Attribute", "attribute": "hp", "value": -30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
using System;
|
||||||
|
using Xunit;
|
||||||
|
using XWorld.Framework.ActorBehavior;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.Tests.ActorBehavior
|
||||||
|
{
|
||||||
|
public class BehaviorConfigCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_LoadsCompleteFighterCatalog()
|
||||||
|
{
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson(
|
||||||
|
"fighter",
|
||||||
|
TestBehaviorJson.Skill,
|
||||||
|
TestBehaviorJson.FlyObject,
|
||||||
|
TestBehaviorJson.Buff);
|
||||||
|
|
||||||
|
Assert.Equal("fighter", catalog.Type);
|
||||||
|
Assert.Equal(1, catalog.Version);
|
||||||
|
Assert.True(catalog.Behaviors.ContainsKey("slash"));
|
||||||
|
Assert.True(catalog.FlyObjects.ContainsKey("blade_wave"));
|
||||||
|
Assert.True(catalog.Buffs.ContainsKey("slow_01"));
|
||||||
|
Assert.Equal(4, catalog.Behaviors["slash"].Timeline.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsMismatchedType()
|
||||||
|
{
|
||||||
|
string mismatchedBuff = TestBehaviorJson.Buff.Replace("\"type\": \"fighter\"", "\"type\": \"mage\"");
|
||||||
|
|
||||||
|
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, TestBehaviorJson.FlyObject, mismatchedBuff));
|
||||||
|
|
||||||
|
Assert.Contains("type", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsMismatchedVersion()
|
||||||
|
{
|
||||||
|
string mismatchedFlyObject = TestBehaviorJson.FlyObject.Replace("\"version\": 1", "\"version\": 2");
|
||||||
|
|
||||||
|
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, mismatchedFlyObject, TestBehaviorJson.Buff));
|
||||||
|
|
||||||
|
Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsMissingReferences()
|
||||||
|
{
|
||||||
|
string badSkill = TestBehaviorJson.Skill.Replace("\"flyObjectId\": \"blade_wave\"", "\"flyObjectId\": \"missing_wave\"");
|
||||||
|
|
||||||
|
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", badSkill, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
|
||||||
|
Assert.Contains("missing_wave", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsInvalidFlyObjectMovementAndCollision()
|
||||||
|
{
|
||||||
|
string badSpeedFlyObject = TestBehaviorJson.FlyObject.Replace("\"speed\": 6.0", "\"speed\": -6.0");
|
||||||
|
string badHitLimitFlyObject = TestBehaviorJson.FlyObject.Replace("\"hitLimit\": 1", "\"hitLimit\": -1");
|
||||||
|
|
||||||
|
InvalidOperationException speedEx = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, badSpeedFlyObject, TestBehaviorJson.Buff));
|
||||||
|
InvalidOperationException hitLimitEx = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, badHitLimitFlyObject, TestBehaviorJson.Buff));
|
||||||
|
|
||||||
|
Assert.Contains("speed", speedEx.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("hitLimit", hitLimitEx.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsInvalidRingAndLineShapeParameters()
|
||||||
|
{
|
||||||
|
InvalidOperationException ringEx = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithInvalidRing, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
InvalidOperationException negativeRingEx = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithNegativeRingMinRadius, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
InvalidOperationException lineEx = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithInvalidLine, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
|
||||||
|
Assert.Contains("minRadius", ringEx.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("minRadius", negativeRingEx.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("width", lineEx.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsTimelineEntriesMissingRequiredFields()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingAnimationName, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingEffectPrefab, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsLogicOpsMissingRequiredFields()
|
||||||
|
{
|
||||||
|
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingAttributeName, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
|
||||||
|
Assert.Contains("attribute", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExampleConfigFiles_LoadFromRepository()
|
||||||
|
{
|
||||||
|
string root = FindRepoRoot();
|
||||||
|
string dir = System.IO.Path.Combine(root, "Client", "Assets", "Resources", "Config", "ActorBehavior");
|
||||||
|
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson(
|
||||||
|
"fighter",
|
||||||
|
System.IO.File.ReadAllText(System.IO.Path.Combine(dir, "fighter_skill.json")),
|
||||||
|
System.IO.File.ReadAllText(System.IO.Path.Combine(dir, "fighter_flyobj.json")),
|
||||||
|
System.IO.File.ReadAllText(System.IO.Path.Combine(dir, "fighter_buff.json")));
|
||||||
|
|
||||||
|
Assert.True(catalog.Behaviors.ContainsKey("slash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
string? dir = AppContext.BaseDirectory;
|
||||||
|
while (dir != null)
|
||||||
|
{
|
||||||
|
string gitPath = System.IO.Path.Combine(dir, ".git");
|
||||||
|
if (System.IO.Directory.Exists(gitPath) || System.IO.File.Exists(gitPath))
|
||||||
|
{
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
dir = System.IO.Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("repo root not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TestBehaviorJson
|
||||||
|
{
|
||||||
|
public const string Skill = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""slash"",
|
||||||
|
""duration"": 0.8,
|
||||||
|
""canBeInterrupted"": true,
|
||||||
|
""input"": ""DirectionOrTarget"",
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""Animation"", ""animation"": ""slash_01"", ""duration"": 0.8 },
|
||||||
|
{ ""time"": 0.12, ""kind"": ""Effect"", ""prefab"": ""Assets/Game/Art/Effect/slash.prefab"", ""duration"": 0.5 },
|
||||||
|
{ ""time"": 0.25, ""kind"": ""LogicEffect"", ""effectId"": ""slash_hit"" },
|
||||||
|
{ ""time"": 0.32, ""kind"": ""Projectile"", ""flyObjectId"": ""blade_wave"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""slash_hit"",
|
||||||
|
""shape"": ""Sector"",
|
||||||
|
""radius"": 2.4,
|
||||||
|
""angle"": 90,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -30 }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string FlyObject = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""flyObjects"": [{
|
||||||
|
""id"": ""blade_wave"",
|
||||||
|
""duration"": 1.5,
|
||||||
|
""movement"": { ""kind"": ""Line"", ""speed"": 6.0 },
|
||||||
|
""collision"": { ""shape"": ""Sphere"", ""radius"": 0.4, ""hitLimit"": 1 },
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""Effect"", ""prefab"": ""Assets/Game/Art/Effect/blade_wave.prefab"", ""duration"": 1.5 },
|
||||||
|
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""blade_wave_hit"", ""trigger"": ""OnCollision"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""blade_wave_hit"",
|
||||||
|
""shape"": ""Locked"",
|
||||||
|
""target"": ""HitActor"",
|
||||||
|
""effects"": [
|
||||||
|
{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -20 },
|
||||||
|
{ ""type"": ""Buff"", ""buffId"": ""slow_01"" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string Buff = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""buffs"": [{
|
||||||
|
""id"": ""slow_01"",
|
||||||
|
""duration"": 3.0,
|
||||||
|
""tickInterval"": 1.0,
|
||||||
|
""repeatCount"": 3,
|
||||||
|
""stacking"": ""RefreshDuration"",
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""slow_tick"",
|
||||||
|
""shape"": ""Locked"",
|
||||||
|
""target"": ""Owner"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""moveSpeed"", ""value"": -0.2 }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string SkillWithBuffTimeline = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""slow_cast"",
|
||||||
|
""duration"": 0.2,
|
||||||
|
""canBeInterrupted"": true,
|
||||||
|
""input"": ""TargetActor"",
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""Buff"", ""buffId"": ""slow_01"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": []
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string SkillWithInvalidRing = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""bad_ring"",
|
||||||
|
""duration"": 0.2,
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""ring_hit"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""ring_hit"",
|
||||||
|
""shape"": ""Ring"",
|
||||||
|
""radius"": 1.0,
|
||||||
|
""minRadius"": 2.0,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string SkillWithNegativeRingMinRadius = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""bad_negative_ring"",
|
||||||
|
""duration"": 0.2,
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""ring_hit"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""ring_hit"",
|
||||||
|
""shape"": ""Ring"",
|
||||||
|
""radius"": 1.0,
|
||||||
|
""minRadius"": -0.1,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string SkillMissingAnimationName = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""bad_animation"",
|
||||||
|
""duration"": 0.2,
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""Animation"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": []
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string SkillMissingEffectPrefab = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""bad_effect"",
|
||||||
|
""duration"": 0.2,
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""Effect"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": []
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string SkillMissingAttributeName = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""bad_attribute"",
|
||||||
|
""duration"": 0.2,
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""bad_hit"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""bad_hit"",
|
||||||
|
""shape"": ""Sphere"",
|
||||||
|
""radius"": 1.0,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
|
||||||
|
public const string SkillWithInvalidLine = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [{
|
||||||
|
""id"": ""bad_line"",
|
||||||
|
""duration"": 0.2,
|
||||||
|
""timeline"": [
|
||||||
|
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""line_hit"" }
|
||||||
|
],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""line_hit"",
|
||||||
|
""shape"": ""Line"",
|
||||||
|
""radius"": 1.0,
|
||||||
|
""width"": -0.2,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using Xunit;
|
||||||
|
using XWorld.Framework.ActorBehavior;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.Tests.ActorBehavior
|
||||||
|
{
|
||||||
|
public class BehaviorTargetingTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("sector_test", 2)]
|
||||||
|
[InlineData("sphere_test", 2)]
|
||||||
|
[InlineData("ring_test", 3)]
|
||||||
|
[InlineData("line_test", 2)]
|
||||||
|
public void LogicEffectShapes_SelectExpectedTargets(string behaviorId, int expectedTarget)
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestTargetWorld.Create();
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, behaviorId, BehaviorInput.FromDirection(1, 0));
|
||||||
|
world.Tick(0.01f);
|
||||||
|
|
||||||
|
Assert.Contains(world.DrainEvents(), e =>
|
||||||
|
e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == expectedTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LockedEffect_UsesTargetActorInput()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestTargetWorld.Create();
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, "locked_test", BehaviorInput.FromTargetActor(3));
|
||||||
|
world.Tick(0.01f);
|
||||||
|
|
||||||
|
Assert.Contains(world.DrainEvents(), e =>
|
||||||
|
e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("sector_test")]
|
||||||
|
[InlineData("line_test")]
|
||||||
|
public void DirectionalShapes_UseInputDirectionInsteadOfActorForward(string behaviorId)
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestTargetWorld.Create();
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, behaviorId, BehaviorInput.FromDirection(0, 1));
|
||||||
|
world.Tick(0.01f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e =>
|
||||||
|
e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 4);
|
||||||
|
Assert.DoesNotContain(events, e =>
|
||||||
|
e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectionalShapes_UseTargetPositionDirectionInsteadOfActorForward()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestTargetWorld.Create();
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, "sector_test", BehaviorInput.FromTargetPosition(0, 0, 2));
|
||||||
|
world.Tick(0.01f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e =>
|
||||||
|
e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 4);
|
||||||
|
Assert.DoesNotContain(events, e =>
|
||||||
|
e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TestTargetWorld
|
||||||
|
{
|
||||||
|
public static BehaviorWorld Create()
|
||||||
|
{
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson(
|
||||||
|
"fighter",
|
||||||
|
Skill,
|
||||||
|
EmptyFlyObject,
|
||||||
|
EmptyBuff);
|
||||||
|
var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ServerLogic });
|
||||||
|
world.RegisterCatalog(catalog);
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 1, CatalogType = "fighter", TeamId = 1, Position = new BehaviorVector3(0, 0, 0), Forward = new BehaviorVector3(1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 2, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(1, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 3, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(3, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 4, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(0, 0, 2), Forward = new BehaviorVector3(0, 0, -1), Radius = 0.3f, Alive = true });
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string EmptyFlyObject = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""flyObjects"": []
|
||||||
|
}";
|
||||||
|
|
||||||
|
private const string EmptyBuff = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""buffs"": []
|
||||||
|
}";
|
||||||
|
|
||||||
|
private const string Skill = @"{
|
||||||
|
""type"": ""fighter"",
|
||||||
|
""version"": 1,
|
||||||
|
""behaviors"": [
|
||||||
|
{
|
||||||
|
""id"": ""sector_test"",
|
||||||
|
""duration"": 0.1,
|
||||||
|
""canBeInterrupted"": true,
|
||||||
|
""timeline"": [{ ""time"": 0, ""kind"": ""LogicEffect"", ""effectId"": ""sector_hit"" }],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""sector_hit"",
|
||||||
|
""shape"": ""Sector"",
|
||||||
|
""radius"": 2,
|
||||||
|
""angle"": 90,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""id"": ""sphere_test"",
|
||||||
|
""duration"": 0.1,
|
||||||
|
""canBeInterrupted"": true,
|
||||||
|
""timeline"": [{ ""time"": 0, ""kind"": ""LogicEffect"", ""effectId"": ""sphere_hit"" }],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""sphere_hit"",
|
||||||
|
""shape"": ""Sphere"",
|
||||||
|
""radius"": 1.5,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""id"": ""ring_test"",
|
||||||
|
""duration"": 0.1,
|
||||||
|
""canBeInterrupted"": true,
|
||||||
|
""timeline"": [{ ""time"": 0, ""kind"": ""LogicEffect"", ""effectId"": ""ring_hit"" }],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""ring_hit"",
|
||||||
|
""shape"": ""Ring"",
|
||||||
|
""minRadius"": 2,
|
||||||
|
""radius"": 3.5,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""id"": ""line_test"",
|
||||||
|
""duration"": 0.1,
|
||||||
|
""canBeInterrupted"": true,
|
||||||
|
""timeline"": [{ ""time"": 0, ""kind"": ""LogicEffect"", ""effectId"": ""line_hit"" }],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""line_hit"",
|
||||||
|
""shape"": ""Line"",
|
||||||
|
""radius"": 2,
|
||||||
|
""width"": 0.5,
|
||||||
|
""target"": ""Enemy"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""id"": ""locked_test"",
|
||||||
|
""duration"": 0.1,
|
||||||
|
""canBeInterrupted"": true,
|
||||||
|
""timeline"": [{ ""time"": 0, ""kind"": ""LogicEffect"", ""effectId"": ""locked_hit"" }],
|
||||||
|
""logicEffects"": [{
|
||||||
|
""id"": ""locked_hit"",
|
||||||
|
""shape"": ""Locked"",
|
||||||
|
""target"": ""HitActor"",
|
||||||
|
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Xunit;
|
||||||
|
using XWorld.Framework.ActorBehavior;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.Tests.ActorBehavior
|
||||||
|
{
|
||||||
|
public class BehaviorWorldTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryStartBehavior_AllowsOnlyOneActiveBehavior()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success);
|
||||||
|
|
||||||
|
BehaviorStartResult second = world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
Assert.False(second.Success);
|
||||||
|
Assert.True(world.HasActiveBehavior(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopBehavior_AllowsInterruptibleBehaviorToSwitch()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
Assert.True(world.StopBehavior(1, BehaviorStopReason.Stunned));
|
||||||
|
Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success);
|
||||||
|
|
||||||
|
Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.BehaviorStopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_TriggersTimelineEventsAtConfiguredTimes()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreatePresentationWorld();
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
world.Tick(0.26f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineAnimation && e.Animation == "slash_01");
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.Prefab == "Assets/Game/Art/Effect/slash.prefab");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ServerLogic_SkipsPresentationEventsAndAppliesLogic()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
world.Tick(0.26f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineAnimation);
|
||||||
|
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineEffect);
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2 && e.Attribute == "hp");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Projectile_SpawnsMovesHitsAndExpiresAtHitLimit()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
world.Tick(0.33f);
|
||||||
|
Assert.True(world.ActiveProjectileCount > 0);
|
||||||
|
|
||||||
|
world.Tick(0.1f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileSpawned);
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileMoved);
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileHit && e.TargetActorId == 2);
|
||||||
|
Assert.Equal(0, world.ActiveProjectileCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Line")]
|
||||||
|
[InlineData("Parabola")]
|
||||||
|
[InlineData("Homing")]
|
||||||
|
public void ProjectileMovementModes_UpdatePosition(string movementKind)
|
||||||
|
{
|
||||||
|
string fly = TestBehaviorJson.FlyObject.Replace(@"""kind"": ""Line""", @"""kind"": """ + movementKind + @"""");
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, fly, TestBehaviorJson.Buff);
|
||||||
|
var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ClientPresentation });
|
||||||
|
world.RegisterCatalog(catalog);
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 1, CatalogType = "fighter", TeamId = 1, Position = new BehaviorVector3(0, 0, 0), Forward = new BehaviorVector3(1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 2, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(4, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromTargetActor(2));
|
||||||
|
world.Tick(0.33f);
|
||||||
|
world.DrainEvents();
|
||||||
|
world.Tick(0.1f);
|
||||||
|
|
||||||
|
Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.ProjectileMoved && e.Position.X > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Buff_TicksByIntervalRepeatCountAndExpires()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
|
||||||
|
Assert.True(world.ApplyBuff(2, "fighter", "slow_01", 1));
|
||||||
|
Assert.Equal(1, world.ActiveBuffCount(2));
|
||||||
|
|
||||||
|
world.Tick(1.0f);
|
||||||
|
world.Tick(1.0f);
|
||||||
|
world.Tick(1.0f);
|
||||||
|
world.Tick(0.1f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffAdded && e.TargetActorId == 2);
|
||||||
|
Assert.Equal(3, events.Count(e => e.Kind == BehaviorEventKind.BuffTicked && e.TargetActorId == 2));
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffRemoved && e.TargetActorId == 2);
|
||||||
|
Assert.Equal(0, world.ActiveBuffCount(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuffTickLogic_AttributesEffectsToOriginalSource()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
|
||||||
|
Assert.True(world.ApplyBuff(2, "fighter", "slow_01", 1));
|
||||||
|
world.DrainEvents();
|
||||||
|
world.Tick(1.0f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e =>
|
||||||
|
e.Kind == BehaviorEventKind.AttributeEffectRequested
|
||||||
|
&& e.SourceActorId == 1
|
||||||
|
&& e.TargetActorId == 2
|
||||||
|
&& e.Attribute == "moveSpeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientPresentation_BuffTickDoesNotEmitAuthoritativeAttributeRequests()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreatePresentationWorld();
|
||||||
|
|
||||||
|
Assert.True(world.ApplyBuff(2, "fighter", "slow_01", 1));
|
||||||
|
world.DrainEvents();
|
||||||
|
world.Tick(1.0f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffTicked && e.TargetActorId == 2);
|
||||||
|
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested);
|
||||||
|
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.LogicEffectApplied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BehaviorTimelineBuff_AppliesConfiguredBuff()
|
||||||
|
{
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithBuffTimeline, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff);
|
||||||
|
var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ServerLogic });
|
||||||
|
world.RegisterCatalog(catalog);
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 1, CatalogType = "fighter", TeamId = 1, Position = new BehaviorVector3(0, 0, 0), Forward = new BehaviorVector3(1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 2, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(1, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, "slow_cast", BehaviorInput.FromTargetActor(2));
|
||||||
|
world.Tick(0.01f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffAdded && e.TargetActorId == 2 && e.BuffId == "slow_01");
|
||||||
|
Assert.Equal(1, world.ActiveBuffCount(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProjectileTimelineEffect_FiresInClientPresentation()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreatePresentationWorld();
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
world.Tick(0.33f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.FlyObjectId == "blade_wave" && e.Prefab == "Assets/Game/Art/Effect/blade_wave.prefab");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Projectile_DoesNotHitSameActorMoreThanOnce()
|
||||||
|
{
|
||||||
|
string fly = TestBehaviorJson.FlyObject.Replace("\"hitLimit\": 1", "\"hitLimit\": 2");
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, fly, TestBehaviorJson.Buff);
|
||||||
|
var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ServerLogic });
|
||||||
|
world.RegisterCatalog(catalog);
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 1, CatalogType = "fighter", TeamId = 1, Position = new BehaviorVector3(0, 0, 0), Forward = new BehaviorVector3(1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 2, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(1, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
world.Tick(0.33f);
|
||||||
|
world.DrainEvents();
|
||||||
|
world.Tick(0.1f);
|
||||||
|
world.Tick(0.1f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Equal(1, events.Count(e => e.Kind == BehaviorEventKind.ProjectileHit && e.TargetActorId == 2));
|
||||||
|
Assert.Equal(1, world.ActiveProjectileCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TestWorld
|
||||||
|
{
|
||||||
|
public static BehaviorWorld CreateServerWorld()
|
||||||
|
{
|
||||||
|
return Create(BehaviorRunMode.ServerLogic);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BehaviorWorld CreatePresentationWorld()
|
||||||
|
{
|
||||||
|
return Create(BehaviorRunMode.ClientPresentation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BehaviorWorld Create(BehaviorRunMode mode)
|
||||||
|
{
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson(
|
||||||
|
"fighter",
|
||||||
|
TestBehaviorJson.Skill,
|
||||||
|
TestBehaviorJson.FlyObject,
|
||||||
|
TestBehaviorJson.Buff);
|
||||||
|
var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = mode });
|
||||||
|
world.RegisterCatalog(catalog);
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 1, CatalogType = "fighter", TeamId = 1, Position = new BehaviorVector3(0, 0, 0), Forward = new BehaviorVector3(1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
world.AddActor(new BehaviorActorState { ActorId = 2, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(1, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true });
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user