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