From 7ef8427e015c1e6570eddde0c7c9f78f7f8b4773 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:10:18 +0800 Subject: [PATCH] feat: add actor behavior config catalog --- .../ActorBehavior/BehaviorConfigCatalog.cs | 289 ++++++++++++++++++ .../ActorBehavior/BehaviorConfigModels.cs | 82 +++++ .../Shared/ActorBehavior/BehaviorJson.cs | 277 +++++++++++++++++ .../Shared/ActorBehavior/BehaviorMath.cs | 65 ++++ .../Shared/ActorBehavior/BehaviorTypes.cs | 179 +++++++++++ .../BehaviorConfigCatalogTests.cs | 118 +++++++ 6 files changed, 1010 insertions(+) create mode 100644 Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs create mode 100644 Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs create mode 100644 Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs create mode 100644 Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs create mode 100644 Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs create mode 100644 Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs new file mode 100644 index 00000000..4711dcb8 --- /dev/null +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; + +namespace XWorld.Framework.ActorBehavior +{ + public sealed class BehaviorConfigCatalog + { + private readonly Dictionary _behaviors; + private readonly Dictionary _flyObjects; + private readonly Dictionary _buffs; + + private BehaviorConfigCatalog(string type, int version, + Dictionary behaviors, + Dictionary flyObjects, + Dictionary buffs) + { + Type = type; + Version = version; + _behaviors = behaviors; + _flyObjects = flyObjects; + _buffs = buffs; + } + + public string Type { get; } + public int Version { get; } + public IReadOnlyDictionary Behaviors => _behaviors; + public IReadOnlyDictionary FlyObjects => _flyObjects; + public IReadOnlyDictionary 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); + 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 ParseBehaviors(JsonArray array) + { + var result = new Dictionary(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 ParseFlyObjects(JsonArray array) + { + var result = new Dictionary(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 ParseBuffs(JsonArray array) + { + var result = new Dictionary(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 ParseTimeline(JsonArray array) + { + var result = new List(); + for (int i = 0; i < array.Count; i++) + { + JsonObject obj = array[i].AsObject(); + var spec = new TimelineEntrySpec + { + Time = obj.OptionalFloat("time"), + Kind = ParseEnum(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 ParseLogicEffects(JsonArray array) + { + var result = new List(); + for (int i = 0; i < array.Count; i++) + { + JsonObject obj = array[i].AsObject(); + var spec = new LogicEffectSpec + { + Id = obj.RequiredString("id"), + Shape = ParseEnum(obj.RequiredString("shape")), + Target = ParseEnum(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 ParseEffectOps(JsonArray array) + { + var result = new List(); + for (int i = 0; i < array.Count; i++) + { + JsonObject obj = array[i].AsObject(); + result.Add(new LogicEffectOpSpec + { + Type = ParseEnum(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); + ValidateDuration(fly.Collision.Radius, "flyObject collision " + 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 timeline, List logicEffects, string owner) + { + var localEffects = new HashSet(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.LogicEffect && !localEffects.Contains(entry.EffectId)) + throw new InvalidOperationException("Missing logic effect reference " + entry.EffectId + " in " + owner); + if (entry.Kind == TimelineEntryKind.Projectile && !_flyObjects.ContainsKey(entry.FlyObjectId)) + throw new InvalidOperationException("Missing flyObject reference " + entry.FlyObjectId + " in " + owner); + if (entry.Kind == TimelineEntryKind.Buff && !_buffs.ContainsKey(entry.BuffId)) + throw new InvalidOperationException("Missing buff reference " + entry.BuffId + " in " + owner); + } + } + + private void ValidateLogicEffects(List effects, string owner) + { + var ids = new HashSet(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.Sector && (effect.Angle <= 0f || effect.Angle > 360f)) + throw new InvalidOperationException("Invalid sector angle for " + effect.Id + " in " + owner); + foreach (LogicEffectOpSpec op in effect.Effects) + { + if (op.Type == LogicEffectOpKind.Buff && !_buffs.ContainsKey(op.BuffId)) + throw new InvalidOperationException("Missing buff reference " + op.BuffId + " in " + owner); + if (op.Type == LogicEffectOpKind.Projectile && !_flyObjects.ContainsKey(op.FlyObjectId)) + throw new InvalidOperationException("Missing flyObject reference " + op.FlyObjectId + " in " + owner); + } + } + } + + private static void ValidateDuration(float value, string owner) + { + if (value < 0f) throw new InvalidOperationException("Invalid duration for " + owner); + } + + private static void AddUnique(Dictionary 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 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(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(string value, T defaultValue) where T : struct + { + return string.IsNullOrEmpty(value) ? defaultValue : ParseEnum(value); + } + } +} diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs new file mode 100644 index 00000000..6dfb82f5 --- /dev/null +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs @@ -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 Timeline = new List(); + public List LogicEffects = new List(); + } + + public sealed class FlyObjectSpec + { + public string Id; + public float Duration; + public FlyMovementSpec Movement = new FlyMovementSpec(); + public CollisionSpec Collision = new CollisionSpec(); + public List Timeline = new List(); + public List LogicEffects = new List(); + } + + public sealed class BuffSpec + { + public string Id; + public float Duration; + public float TickInterval; + public int RepeatCount; + public BuffStackingKind Stacking; + public List LogicEffects = new List(); + } + + 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 Effects = new List(); + } + + 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; + } +} diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs new file mode 100644 index 00000000..fdde2775 --- /dev/null +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs @@ -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 _values = new Dictionary(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 _values = new List(); + + 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(); + 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); + } + } + } +} diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs new file mode 100644 index 00000000..e9292cfa --- /dev/null +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs @@ -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; + } + } +} diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs new file mode 100644 index 00000000..57acfbae --- /dev/null +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs @@ -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; + } +} diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs new file mode 100644 index 00000000..7028fe94 --- /dev/null +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs @@ -0,0 +1,118 @@ +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(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, TestBehaviorJson.FlyObject, mismatchedBuff)); + + Assert.Contains("type", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LoadFromJson_RejectsMissingReferences() + { + string badSkill = TestBehaviorJson.Skill.Replace("\"flyObjectId\": \"blade_wave\"", "\"flyObjectId\": \"missing_wave\""); + + InvalidOperationException ex = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", badSkill, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("missing_wave", ex.Message); + } + } + + 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 }] + }] + }] + }"; + } +}