From 7ef8427e015c1e6570eddde0c7c9f78f7f8b4773 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:10:18 +0800 Subject: [PATCH 1/8] 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 }] + }] + }] + }"; + } +} From 1517d46fc030035de080efad84e8da3092e22271 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:12:27 +0800 Subject: [PATCH 2/8] feat: add actor behavior runtime --- .../Shared/ActorBehavior/BehaviorWorld.cs | 301 ++++++++++++++++++ .../ActorBehavior/BehaviorWorldTests.cs | 86 +++++ 2 files changed, 387 insertions(+) create mode 100644 Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs create mode 100644 Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs new file mode 100644 index 00000000..641da895 --- /dev/null +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; + +namespace XWorld.Framework.ActorBehavior +{ + public sealed class BehaviorWorld + { + private readonly BehaviorWorldOptions _options; + private readonly Dictionary _catalogs = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _actors = new Dictionary(); + private readonly Dictionary _activeBehaviors = new Dictionary(); + private readonly List _events = new List(); + + public BehaviorWorld(BehaviorWorldOptions options) + { + _options = options; + } + + public int ActiveProjectileCount => 0; + + 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; + var completed = new List(); + foreach (ActiveBehavior active in new List(_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 DrainEvents() + { + var drained = _events.ToArray(); + _events.Clear(); + return drained; + } + + public bool HasActiveBehavior(int actorId) + { + return _activeBehaviors.ContainsKey(actorId); + } + + public int ActiveBuffCount(int actorId) + { + return 0; + } + + public bool ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId) + { + return false; + } + + private void FireTimeline(ActiveBehavior active, float previousElapsed, float currentElapsed) + { + List 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); + } + } + + private void ApplyLogicEffect(int sourceActorId, BehaviorConfigCatalog catalog, LogicEffectSpec effect, BehaviorInput input, int hitActorId) + { + foreach (int targetActorId in SelectTargets(sourceActorId, effect, input, hitActorId)) + { + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.LogicEffectApplied, + ActorId = sourceActorId, + SourceActorId = sourceActorId, + 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 = sourceActorId, + SourceActorId = sourceActorId, + TargetActorId = targetActorId, + LogicEffectId = effect.Id, + Attribute = op.Attribute, + Value = op.Value + }); + } + } + } + } + + private IEnumerable 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)) + 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 static bool IsInsideShape(BehaviorActorState source, BehaviorActorState target, LogicEffectSpec effect) + { + 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 = source.Forward.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 + source.Forward.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 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 FiredTimeline = new HashSet(); + } + } +} diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs new file mode 100644 index 00000000..ba162123 --- /dev/null +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -0,0 +1,86 @@ +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"); + } + } + + 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; + } + } +} From 9e946c77725787fa128956e27d6af70be1ecad83 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:13:49 +0800 Subject: [PATCH 3/8] test: cover actor behavior targeting shapes --- .../ActorBehavior/BehaviorTargetingTests.cs | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs new file mode 100644 index 00000000..b0e78d4b --- /dev/null +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs @@ -0,0 +1,141 @@ +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); + } + } + + 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 }] + }] + } + ] + }"; + } +} From 52741e68a8a9109b8bbae49860b13ce3cca45711 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:15:55 +0800 Subject: [PATCH 4/8] feat: add actor behavior projectiles --- .../Shared/ActorBehavior/BehaviorWorld.cs | 166 +++++++++++++++++- .../ActorBehavior/BehaviorWorldTests.cs | 39 ++++ 2 files changed, 204 insertions(+), 1 deletion(-) diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs index 641da895..b28c288c 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -9,14 +9,16 @@ namespace XWorld.Framework.ActorBehavior private readonly Dictionary _catalogs = new Dictionary(StringComparer.Ordinal); private readonly Dictionary _actors = new Dictionary(); private readonly Dictionary _activeBehaviors = new Dictionary(); + private readonly List _projectiles = new List(); private readonly List _events = new List(); + private int _nextProjectileId = 1; public BehaviorWorld(BehaviorWorldOptions options) { _options = options; } - public int ActiveProjectileCount => 0; + public int ActiveProjectileCount => _projectiles.Count; public void RegisterCatalog(BehaviorConfigCatalog catalog) { @@ -81,6 +83,8 @@ namespace XWorld.Framework.ActorBehavior public void Tick(float deltaTime) { if (deltaTime <= 0f) return; + TickProjectiles(deltaTime); + var completed = new List(); foreach (ActiveBehavior active in new List(_activeBehaviors.Values)) { @@ -171,6 +175,12 @@ namespace XWorld.Framework.ActorBehavior { 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.Projectile) + { + SpawnProjectile(active.ActorId, active.Catalog, entry.FlyObjectId, active.Input); } } @@ -203,10 +213,150 @@ namespace XWorld.Framework.ActorBehavior Value = op.Value }); } + else if (op.Type == LogicEffectOpKind.Projectile) + { + SpawnProjectile(sourceActorId, catalog, op.FlyObjectId, input); + } } } } + 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 + }); + } + + 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(); + foreach (ActiveProjectile projectile in _projectiles) + { + 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 + }); + + 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 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; + float distance = BehaviorVector3.DistanceXZ(projectile.Position, actor.Position); + if (distance > projectile.Spec.Collision.Radius + actor.Radius) continue; + + projectile.HitCount++; + _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 FireProjectileCollisionLogic(ActiveProjectile projectile, int hitActorId) + { + List 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 SelectTargets(int sourceActorId, LogicEffectSpec effect, BehaviorInput input, int hitActorId) { if (!_actors.TryGetValue(sourceActorId, out BehaviorActorState source)) @@ -297,5 +447,19 @@ namespace XWorld.Framework.ActorBehavior public float Elapsed; public readonly HashSet FiredTimeline = new HashSet(); } + + 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; + } } } diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs index ba162123..62c67530 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -55,6 +55,45 @@ namespace XWorld.Framework.Tests.ActorBehavior 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); + } } internal static class TestWorld From c12e9ea9d77775c68462547a25cb990bdf226962 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:17:41 +0800 Subject: [PATCH 5/8] feat: add actor behavior buffs --- .../Shared/ActorBehavior/BehaviorWorld.cs | 130 +++++++++++++++++- .../ActorBehavior/BehaviorWorldTests.cs | 21 +++ 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs index b28c288c..15694be1 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -10,6 +10,7 @@ namespace XWorld.Framework.ActorBehavior private readonly Dictionary _actors = new Dictionary(); private readonly Dictionary _activeBehaviors = new Dictionary(); private readonly List _projectiles = new List(); + private readonly Dictionary> _buffs = new Dictionary>(); private readonly List _events = new List(); private int _nextProjectileId = 1; @@ -84,6 +85,7 @@ namespace XWorld.Framework.ActorBehavior { if (deltaTime <= 0f) return; TickProjectiles(deltaTime); + TickBuffs(deltaTime); var completed = new List(); foreach (ActiveBehavior active in new List(_activeBehaviors.Values)) @@ -117,12 +119,68 @@ namespace XWorld.Framework.ActorBehavior public int ActiveBuffCount(int actorId) { - return 0; + return _buffs.TryGetValue(actorId, out List buffs) ? buffs.Count : 0; } public bool ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId) { - return false; + 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 activeBuffs)) + { + activeBuffs = new List(); + _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) @@ -217,10 +275,67 @@ namespace XWorld.Framework.ActorBehavior { SpawnProjectile(sourceActorId, catalog, op.FlyObjectId, input); } + else if (op.Type == LogicEffectOpKind.Buff) + { + ApplyBuff(targetActorId, catalog.Type, op.BuffId, sourceActorId); + } } } } + private void TickBuffs(float deltaTime) + { + var actorIds = new List(_buffs.Keys); + for (int actorIndex = 0; actorIndex < actorIds.Count; actorIndex++) + { + int actorId = actorIds[actorIndex]; + List activeBuffs = _buffs[actorId]; + var expired = new List(); + 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 + }); + for (int e = 0; e < buff.Spec.LogicEffects.Count; e++) + ApplyLogicEffect(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; @@ -461,5 +576,16 @@ namespace XWorld.Framework.ActorBehavior public float VerticalVelocity; public int HitCount; } + + 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; + } } } diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs index 62c67530..dc9677e2 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using Xunit; using XWorld.Framework.ActorBehavior; @@ -94,6 +95,26 @@ namespace XWorld.Framework.Tests.ActorBehavior 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)); + } } internal static class TestWorld From 1380d5107a6b2e70d92c1556bc22871f0521eee2 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:19:45 +0800 Subject: [PATCH 6/8] feat: add actor behavior example config --- .../Config/ActorBehavior/fighter_buff.json | 23 ++++++++++++++ .../Config/ActorBehavior/fighter_flyobj.json | 27 +++++++++++++++++ .../Config/ActorBehavior/fighter_skill.json | 30 +++++++++++++++++++ .../BehaviorConfigCatalogTests.cs | 30 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json create mode 100644 Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json create mode 100644 Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json diff --git a/Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json b/Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json new file mode 100644 index 00000000..2c6fc5a5 --- /dev/null +++ b/Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json @@ -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 } + ] + } + ] + } + ] +} diff --git a/Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json b/Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json new file mode 100644 index 00000000..8f8f3995 --- /dev/null +++ b/Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json @@ -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" } + ] + } + ] + } + ] +} diff --git a/Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json b/Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json new file mode 100644 index 00000000..fcfb338c --- /dev/null +++ b/Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json @@ -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 } + ] + } + ] + } + ] +} diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs index 7028fe94..3a279805 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs @@ -44,6 +44,36 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains("missing_wave", ex.Message); } + + [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 From bc52b74ff87a2ee2cee6f2fb195e486bec1529f0 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:29:35 +0800 Subject: [PATCH 7/8] fix: complete actor behavior timelines --- .../ActorBehavior/BehaviorConfigCatalog.cs | 8 ++ .../Shared/ActorBehavior/BehaviorWorld.cs | 66 ++++++++++- .../BehaviorConfigCatalogTests.cs | 105 ++++++++++++++++++ .../ActorBehavior/BehaviorWorldTests.cs | 44 ++++++++ 4 files changed, 221 insertions(+), 2 deletions(-) diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs index 4711dcb8..7860e55c 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs @@ -204,7 +204,11 @@ namespace XWorld.Framework.ActorBehavior 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); } @@ -242,8 +246,12 @@ namespace XWorld.Framework.ActorBehavior 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 && !_buffs.ContainsKey(op.BuffId)) diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs index 15694be1..9a79c9c6 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -236,6 +236,13 @@ namespace XWorld.Framework.ActorBehavior 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); @@ -308,8 +315,11 @@ namespace XWorld.Framework.ActorBehavior TargetActorId = buff.TargetActorId, BuffId = buff.Spec.Id }); - for (int e = 0; e < buff.Spec.LogicEffects.Count; e++) - ApplyLogicEffect(buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId); + if (_options.Mode == BehaviorRunMode.ServerLogic) + { + for (int e = 0; e < buff.Spec.LogicEffects.Count; e++) + ApplyLogicEffect(buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId); + } buff.NextTickTime += buff.Spec.TickInterval; } @@ -364,6 +374,7 @@ namespace XWorld.Framework.ActorBehavior FlyObjectId = spec.Id, Position = projectile.Position }); + FireProjectileTimeline(projectile, -0.00001f, 0f); } private BehaviorVector3 ResolveProjectileDirection(BehaviorActorState source, BehaviorInput input, int targetActorId) @@ -382,6 +393,7 @@ namespace XWorld.Framework.ActorBehavior var expired = new HashSet(); foreach (ActiveProjectile projectile in _projectiles) { + float previousAge = projectile.Age; projectile.Age += deltaTime; MoveProjectile(projectile, deltaTime); _events.Add(new BehaviorEvent @@ -392,6 +404,7 @@ namespace XWorld.Framework.ActorBehavior FlyObjectId = projectile.Spec.Id, Position = projectile.Position }); + FireProjectileTimeline(projectile, previousAge, projectile.Age); if (_options.Mode == BehaviorRunMode.ServerLogic) CheckProjectileCollision(projectile, expired); @@ -458,6 +471,54 @@ namespace XWorld.Framework.ActorBehavior } } + private void FireProjectileTimeline(ActiveProjectile projectile, float previousElapsed, float currentElapsed) + { + List 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 timeline = projectile.Spec.Timeline; @@ -575,6 +636,7 @@ namespace XWorld.Framework.ActorBehavior public float Age; public float VerticalVelocity; public int HitCount; + public readonly HashSet FiredTimeline = new HashSet(); } private sealed class ActiveBuff diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs index 3a279805..5402a4a3 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs @@ -45,6 +45,36 @@ namespace XWorld.Framework.Tests.ActorBehavior 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(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, badSpeedFlyObject, TestBehaviorJson.Buff)); + InvalidOperationException hitLimitEx = Assert.Throws(() => + 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(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithInvalidRing, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + InvalidOperationException negativeRingEx = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithNegativeRingMinRadius, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + InvalidOperationException lineEx = Assert.Throws(() => + 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 ExampleConfigFiles_LoadFromRepository() { @@ -144,5 +174,80 @@ namespace XWorld.Framework.Tests.ActorBehavior }] }] }"; + + 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 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 }] + }] + }] + }"; } } diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs index dc9677e2..3cbcc752 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -115,6 +115,50 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffRemoved && e.TargetActorId == 2); Assert.Equal(0, world.ActiveBuffCount(2)); } + + [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"); + } } internal static class TestWorld From f898cbab10e5f2281a7d939ba6673dddb3795aaa Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:38:10 +0800 Subject: [PATCH 8/8] fix: harden actor behavior authority logic --- .../ActorBehavior/BehaviorConfigCatalog.cs | 55 +++++++++++--- .../Shared/ActorBehavior/BehaviorWorld.cs | 45 ++++++++--- .../BehaviorConfigCatalogTests.cs | 74 +++++++++++++++++++ .../ActorBehavior/BehaviorTargetingTests.cs | 32 ++++++++ .../ActorBehavior/BehaviorWorldTests.cs | 38 ++++++++++ 5 files changed, 222 insertions(+), 22 deletions(-) diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs index 7860e55c..b0fbe58c 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs @@ -41,6 +41,10 @@ namespace XWorld.Framework.ActorBehavior 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")); @@ -229,12 +233,31 @@ namespace XWorld.Framework.ActorBehavior 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); + 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); + } } } @@ -254,10 +277,22 @@ namespace XWorld.Framework.ActorBehavior throw new InvalidOperationException("Invalid width 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); + 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); } } } diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs index 9a79c9c6..dccffc82 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -251,13 +251,18 @@ namespace XWorld.Framework.ActorBehavior private void ApplyLogicEffect(int sourceActorId, BehaviorConfigCatalog catalog, LogicEffectSpec effect, BehaviorInput input, int hitActorId) { - foreach (int targetActorId in SelectTargets(sourceActorId, effect, input, 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 = sourceActorId, - SourceActorId = sourceActorId, + ActorId = eventSourceActorId, + SourceActorId = eventSourceActorId, TargetActorId = targetActorId, LogicEffectId = effect.Id }); @@ -270,8 +275,8 @@ namespace XWorld.Framework.ActorBehavior _events.Add(new BehaviorEvent { Kind = BehaviorEventKind.AttributeEffectRequested, - ActorId = sourceActorId, - SourceActorId = sourceActorId, + ActorId = eventSourceActorId, + SourceActorId = eventSourceActorId, TargetActorId = targetActorId, LogicEffectId = effect.Id, Attribute = op.Attribute, @@ -280,11 +285,11 @@ namespace XWorld.Framework.ActorBehavior } else if (op.Type == LogicEffectOpKind.Projectile) { - SpawnProjectile(sourceActorId, catalog, op.FlyObjectId, input); + SpawnProjectile(originActorId, catalog, op.FlyObjectId, input); } else if (op.Type == LogicEffectOpKind.Buff) { - ApplyBuff(targetActorId, catalog.Type, op.BuffId, sourceActorId); + ApplyBuff(targetActorId, catalog.Type, op.BuffId, eventSourceActorId); } } } @@ -318,7 +323,7 @@ namespace XWorld.Framework.ActorBehavior if (_options.Mode == BehaviorRunMode.ServerLogic) { for (int e = 0; e < buff.Spec.LogicEffects.Count; e++) - ApplyLogicEffect(buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId); + ApplyLogicEffect(buff.SourceActorId, buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId); } buff.NextTickTime += buff.Spec.TickInterval; } @@ -448,10 +453,12 @@ namespace XWorld.Framework.ActorBehavior 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, @@ -549,7 +556,7 @@ namespace XWorld.Framework.ActorBehavior { if (!actor.Alive) continue; if (!PassesTargetFilter(source, actor, effect.Target)) continue; - if (IsInsideShape(source, actor, effect)) + if (IsInsideShape(source, actor, effect, ResolveEffectDirection(source, input))) yield return actor.ActorId; } } @@ -565,7 +572,20 @@ namespace XWorld.Framework.ActorBehavior return true; } - private static bool IsInsideShape(BehaviorActorState source, BehaviorActorState target, LogicEffectSpec effect) + 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) @@ -576,7 +596,7 @@ namespace XWorld.Framework.ActorBehavior { if (distance > effect.Radius + target.Radius) return false; BehaviorVector3 toTarget = (target.Position - source.Position).NormalizedXZ(); - BehaviorVector3 forward = source.Forward.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; @@ -584,7 +604,7 @@ namespace XWorld.Framework.ActorBehavior if (effect.Shape == LogicShapeKind.Line) { float width = effect.Width > 0f ? effect.Width : target.Radius; - float dist = DistancePointToSegmentXZ(target.Position, source.Position, source.Position + source.Forward.NormalizedXZ() * effect.Radius); + float dist = DistancePointToSegmentXZ(target.Position, source.Position, source.Position + direction.NormalizedXZ() * effect.Radius); return dist <= width + target.Radius; } return target.ActorId == source.ActorId; @@ -637,6 +657,7 @@ namespace XWorld.Framework.ActorBehavior public float VerticalVelocity; public int HitCount; public readonly HashSet FiredTimeline = new HashSet(); + public readonly HashSet HitActorIds = new HashSet(); } private sealed class ActiveBuff diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs index 5402a4a3..98d0a237 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs @@ -34,6 +34,17 @@ namespace XWorld.Framework.Tests.ActorBehavior 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(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, mismatchedFlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void LoadFromJson_RejectsMissingReferences() { @@ -75,6 +86,24 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.Contains("width", lineEx.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void LoadFromJson_RejectsTimelineEntriesMissingRequiredFields() + { + Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingAnimationName, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingEffectPrefab, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + } + + [Fact] + public void LoadFromJson_RejectsLogicOpsMissingRequiredFields() + { + InvalidOperationException ex = Assert.Throws(() => + BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillMissingAttributeName, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff)); + + Assert.Contains("attribute", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ExampleConfigFiles_LoadFromRepository() { @@ -230,6 +259,51 @@ namespace XWorld.Framework.Tests.ActorBehavior }] }"; + 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, diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs index b0e78d4b..4e9acdff 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs @@ -32,6 +32,38 @@ namespace XWorld.Framework.Tests.ActorBehavior 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 diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs index 3cbcc752..06433ad9 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -116,6 +116,23 @@ namespace XWorld.Framework.Tests.ActorBehavior 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() { @@ -159,6 +176,27 @@ namespace XWorld.Framework.Tests.ActorBehavior 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