merge actor behavior system

This commit is contained in:
ud18010
2026-07-30 15:05:45 +08:00
12 changed files with 2417 additions and 0 deletions
@@ -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;
}
}
}