docs: plan actor behavior system
This commit is contained in:
@@ -0,0 +1,755 @@
|
|||||||
|
# 角色行为(技能)系统实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 实现共享层角色行为核心,可从 JSON 加载技能、飞行物、Buff 和逻辑效果配置,并用 xUnit 覆盖第一版权威逻辑。
|
||||||
|
|
||||||
|
**Architecture:** 在 `Client/Assets/Framework/Shared/ActorBehavior/` 新增纯 C# 共享模块,由 `Server/Framework.Shared` 同源编译。运行时通过 `BehaviorWorld` 管理角色、主动行为、飞行物和 Buff,并输出 `BehaviorEvent` 给服务端逻辑或客户端表现层消费。
|
||||||
|
|
||||||
|
**Tech Stack:** C# 9.0, netstandard2.1 shared library, xUnit tests in `Server/Framework.Shared.Tests`, no UnityEngine dependency, no external JSON package.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- 第一版只实现共享核心、示例配置和自动化测试。
|
||||||
|
- 不直接接入大厅角色控制器。
|
||||||
|
- 不直接接入某个小游戏 UI。
|
||||||
|
- 共享核心不得引用 Unity。
|
||||||
|
- 客户端和服务端加载同一套 JSON。
|
||||||
|
- 服务端只运行权威逻辑;客户端表现模式不执行权威属性结算。
|
||||||
|
- JSON 解析不得引入新的 NuGet 依赖,避免破坏 Unity 和服务端同源编译。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs`
|
||||||
|
- `BehaviorVector2`、`BehaviorVector3` 和基础几何方法。
|
||||||
|
- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs`
|
||||||
|
- 枚举、输入、事件、角色状态、运行选项、结果类型。
|
||||||
|
- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs`
|
||||||
|
- JSON 对应的配置模型。
|
||||||
|
- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs`
|
||||||
|
- 轻量 JSON parser 和模型转换,不依赖外部包。
|
||||||
|
- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs`
|
||||||
|
- 配置加载、索引和校验。
|
||||||
|
- `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs`
|
||||||
|
- 行为、飞行物、Buff、逻辑效果运行时。
|
||||||
|
- `Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json`
|
||||||
|
- 示例技能配置。
|
||||||
|
- `Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json`
|
||||||
|
- 示例飞行物配置。
|
||||||
|
- `Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json`
|
||||||
|
- 示例 Buff 配置。
|
||||||
|
- `Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs`
|
||||||
|
- 配置解析和校验测试。
|
||||||
|
- `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs`
|
||||||
|
- 行为时间线、模式过滤、飞行物和 Buff 测试。
|
||||||
|
- `Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs`
|
||||||
|
- 扇形、球形、环形、直线、锁定目标测试。
|
||||||
|
|
||||||
|
Modify:
|
||||||
|
|
||||||
|
- No project file changes expected. `Server/Framework.Shared/Framework.Shared.csproj` already compiles `Client/Assets/Framework/Shared/**/*.cs`.
|
||||||
|
|
||||||
|
## Shared Interfaces
|
||||||
|
|
||||||
|
The implementation must expose these public APIs:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace XWorld.Framework.ActorBehavior
|
||||||
|
{
|
||||||
|
public sealed class BehaviorConfigCatalog
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public int Version { get; }
|
||||||
|
public IReadOnlyDictionary<string, BehaviorSpec> Behaviors { get; }
|
||||||
|
public IReadOnlyDictionary<string, FlyObjectSpec> FlyObjects { get; }
|
||||||
|
public IReadOnlyDictionary<string, BuffSpec> Buffs { get; }
|
||||||
|
public static BehaviorConfigCatalog LoadFromJson(string type, string skillJson, string flyObjectJson, string buffJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BehaviorWorld
|
||||||
|
{
|
||||||
|
public BehaviorWorld(BehaviorWorldOptions options);
|
||||||
|
public void RegisterCatalog(BehaviorConfigCatalog catalog);
|
||||||
|
public void AddActor(BehaviorActorState actor);
|
||||||
|
public bool SwitchActorCatalog(int actorId, string catalogType);
|
||||||
|
public BehaviorStartResult TryStartBehavior(int actorId, string behaviorId, BehaviorInput input);
|
||||||
|
public bool StopBehavior(int actorId, BehaviorStopReason reason);
|
||||||
|
public void Tick(float deltaTime);
|
||||||
|
public IReadOnlyList<BehaviorEvent> DrainEvents();
|
||||||
|
public bool HasActiveBehavior(int actorId);
|
||||||
|
public int ActiveProjectileCount { get; }
|
||||||
|
public int ActiveBuffCount(int actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 1: 配置模型和 JSON 加载
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs`
|
||||||
|
- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorTypes.cs`
|
||||||
|
- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigModels.cs`
|
||||||
|
- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorJson.cs`
|
||||||
|
- Create: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorConfigCatalog.cs`
|
||||||
|
- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `BehaviorConfigCatalog.LoadFromJson(string type, string skillJson, string flyObjectJson, string buffJson)`
|
||||||
|
- Produces: model classes `BehaviorSpec`, `FlyObjectSpec`, `BuffSpec`, `LogicEffectSpec`, `TimelineEntrySpec`
|
||||||
|
- Produces: enums `TimelineEntryKind`, `LogicShapeKind`, `LogicEffectOpKind`, `FlyMovementKind`, `BuffStackingKind`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing config loading tests**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using Xunit;
|
||||||
|
using XWorld.Framework.ActorBehavior;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.Tests.ActorBehavior
|
||||||
|
{
|
||||||
|
public class BehaviorConfigCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_LoadsCompleteFighterCatalog()
|
||||||
|
{
|
||||||
|
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson(
|
||||||
|
"fighter",
|
||||||
|
TestBehaviorJson.Skill,
|
||||||
|
TestBehaviorJson.FlyObject,
|
||||||
|
TestBehaviorJson.Buff);
|
||||||
|
|
||||||
|
Assert.Equal("fighter", catalog.Type);
|
||||||
|
Assert.Equal(1, catalog.Version);
|
||||||
|
Assert.True(catalog.Behaviors.ContainsKey("slash"));
|
||||||
|
Assert.True(catalog.FlyObjects.ContainsKey("blade_wave"));
|
||||||
|
Assert.True(catalog.Buffs.ContainsKey("slow_01"));
|
||||||
|
Assert.Equal(4, catalog.Behaviors["slash"].Timeline.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsMismatchedType()
|
||||||
|
{
|
||||||
|
string mismatchedBuff = TestBehaviorJson.Buff.Replace("\"type\": \"fighter\"", "\"type\": \"mage\"");
|
||||||
|
|
||||||
|
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, TestBehaviorJson.FlyObject, mismatchedBuff));
|
||||||
|
|
||||||
|
Assert.Contains("type", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RejectsMissingReferences()
|
||||||
|
{
|
||||||
|
string badSkill = TestBehaviorJson.Skill.Replace("\"flyObjectId\": \"blade_wave\"", "\"flyObjectId\": \"missing_wave\"");
|
||||||
|
|
||||||
|
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
BehaviorConfigCatalog.LoadFromJson("fighter", badSkill, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
|
||||||
|
|
||||||
|
Assert.Contains("missing_wave", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add test fixture JSON**
|
||||||
|
|
||||||
|
Add a `TestBehaviorJson` helper in the same test file:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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 }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify red**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorConfigCatalogTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `XWorld.Framework.ActorBehavior` types do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement minimal config models and parser**
|
||||||
|
|
||||||
|
Create the files listed above. Implement:
|
||||||
|
|
||||||
|
- A recursive JSON parser supporting object, array, string, number, boolean, and null.
|
||||||
|
- Typed conversion helpers for required strings and optional numbers.
|
||||||
|
- Public config model classes with public fields/properties.
|
||||||
|
- Catalog validation for type mismatch, missing references, duplicates, invalid duration, and invalid shapes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify green**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorConfigCatalogTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 2: 行为状态机和时间线事件
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs`
|
||||||
|
- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `BehaviorConfigCatalog`
|
||||||
|
- Produces: `BehaviorWorld.TryStartBehavior`, `StopBehavior`, `Tick`, `DrainEvents`, `HasActiveBehavior`
|
||||||
|
- Produces: `BehaviorEventKind.BehaviorStarted`, `BehaviorStopped`, `TimelineAnimation`, `TimelineEffect`, `LogicEffectApplied`, `AttributeEffectRequested`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing behavior runtime tests**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Linq;
|
||||||
|
using Xunit;
|
||||||
|
using XWorld.Framework.ActorBehavior;
|
||||||
|
|
||||||
|
namespace XWorld.Framework.Tests.ActorBehavior
|
||||||
|
{
|
||||||
|
public class BehaviorWorldTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryStartBehavior_AllowsOnlyOneActiveBehavior()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success);
|
||||||
|
|
||||||
|
BehaviorStartResult second = world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
Assert.False(second.Success);
|
||||||
|
Assert.True(world.HasActiveBehavior(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopBehavior_AllowsInterruptibleBehaviorToSwitch()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
Assert.True(world.StopBehavior(1, BehaviorStopReason.Stunned));
|
||||||
|
Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success);
|
||||||
|
|
||||||
|
Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.BehaviorStopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_TriggersTimelineEventsAtConfiguredTimes()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreatePresentationWorld();
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
world.Tick(0.26f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineAnimation && e.Animation == "slash_01");
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.Prefab == "Assets/Game/Art/Effect/slash.prefab");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ServerLogic_SkipsPresentationEventsAndAppliesLogic()
|
||||||
|
{
|
||||||
|
BehaviorWorld world = TestWorld.CreateServerWorld();
|
||||||
|
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
|
||||||
|
|
||||||
|
world.Tick(0.26f);
|
||||||
|
var events = world.DrainEvents();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineAnimation);
|
||||||
|
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineEffect);
|
||||||
|
Assert.Contains(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2 && e.Attribute == "hp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add shared test world helper**
|
||||||
|
|
||||||
|
Add to `BehaviorWorldTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify red**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorWorldTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because runtime behavior is not implemented.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement behavior runtime**
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- Actor dictionary.
|
||||||
|
- Catalog registration.
|
||||||
|
- Active behavior state with elapsed time and fired timeline indices.
|
||||||
|
- `TryStartBehavior` returning `BehaviorStartResult`.
|
||||||
|
- Immediate firing of timeline entries at `time <= 0`.
|
||||||
|
- `Tick` firing timeline entries when `previousElapsed < entry.time <= currentElapsed`.
|
||||||
|
- Auto-stop when elapsed reaches behavior duration.
|
||||||
|
- Run-mode filtering: presentation events only in `ClientPresentation`, logic application only in `ServerLogic`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify green**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorWorldTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS for behavior state and timeline tests.
|
||||||
|
|
||||||
|
## Task 3: 目标选择和逻辑效果
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs`
|
||||||
|
- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorMath.cs`
|
||||||
|
- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorTargetingTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `LogicEffectSpec`
|
||||||
|
- Produces: target selection for `Sector`, `Sphere`, `Ring`, `Line`, `Locked`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing targeting tests**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Linq;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add targeting fixture JSON helper**
|
||||||
|
|
||||||
|
Create `TestTargetWorld` in the same file. It should build a catalog with five behaviors, each firing one `LogicEffect` at `time: 0`, and actors at:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
actor 1: position (0,0,0), forward (1,0,0), team 1
|
||||||
|
actor 2: position (1,0,0), team 2
|
||||||
|
actor 3: position (3,0,0), team 2
|
||||||
|
actor 4: position (0,0,2), team 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Use shape parameters:
|
||||||
|
|
||||||
|
- `sector_test`: `Sector`, radius `2`, angle `90`, target `Enemy`.
|
||||||
|
- `sphere_test`: `Sphere`, radius `1.5`, target `Enemy`.
|
||||||
|
- `ring_test`: `Ring`, minRadius `2`, radius `3.5`, target `Enemy`.
|
||||||
|
- `line_test`: `Line`, radius `2`, width `0.5`, target `Enemy`.
|
||||||
|
- `locked_test`: `Locked`, target `HitActor`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify red**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorTargetingTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until shape selection is implemented.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement shape selection**
|
||||||
|
|
||||||
|
Implement geometry in XZ plane:
|
||||||
|
|
||||||
|
- `Sphere`: distance from origin to actor center is within `radius + actor.Radius`.
|
||||||
|
- `Ring`: distance is between `minRadius` and `radius + actor.Radius`.
|
||||||
|
- `Sector`: within radius and absolute angle from forward is within `angle / 2`.
|
||||||
|
- `Line`: distance from actor center to segment `[origin, origin + forward * radius]` is within `width + actor.Radius`.
|
||||||
|
- `Locked`: choose input target actor, owner, or hit actor according to target filter.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify green**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter BehaviorTargetingTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 4: 飞行物运行时和碰撞
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs`
|
||||||
|
- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ActiveProjectileCount`
|
||||||
|
- Produces: `BehaviorEventKind.ProjectileSpawned`, `ProjectileMoved`, `ProjectileHit`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing projectile tests**
|
||||||
|
|
||||||
|
Add tests to `BehaviorWorldTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run projectile tests to verify red**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Projectile"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until projectile runtime is implemented.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement projectile runtime**
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- Projectile spawn from timeline entries with `kind: Projectile`.
|
||||||
|
- Position starts at owner position.
|
||||||
|
- Direction comes from `BehaviorInput.Direction`, target actor direction, or actor forward.
|
||||||
|
- `Line`: `position += direction * speed * dt`.
|
||||||
|
- `Parabola`: horizontal direction with speed, vertical velocity affected by gravity; keep XZ deterministic.
|
||||||
|
- `Homing`: direction recalculated toward target actor each tick.
|
||||||
|
- Sphere collision against alive enemy actors.
|
||||||
|
- On collision emit `ProjectileHit`, run `OnCollision` logic effect, increment hit count, expire at `hitLimit`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run projectile tests to verify green**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Projectile"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 5: Buff 运行时
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs`
|
||||||
|
- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ActiveBuffCount(int actorId)`
|
||||||
|
- Produces: `BehaviorEventKind.BuffAdded`, `BuffTicked`, `BuffRemoved`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing buff test**
|
||||||
|
|
||||||
|
Add to `BehaviorWorldTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the public `ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId)` API. Do not add test-only production methods.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run buff test to verify red**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Buff"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until buff runtime is implemented.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement buff runtime**
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- Public `ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId)`.
|
||||||
|
- Stacking modes `RefreshDuration`, `IgnoreIfExists`, `StackIndependent`.
|
||||||
|
- Tick elapsed duration.
|
||||||
|
- Fire `BuffTicked` when elapsed crosses each interval.
|
||||||
|
- Respect `repeatCount`.
|
||||||
|
- Fire `BuffRemoved` when duration expires or repeat count is reached.
|
||||||
|
- Logic operations of type `Buff` should call `ApplyBuff`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run buff tests to verify green**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter "Buff"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 6: 示例配置、全量验证和 cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Client/Assets/Resources/Config/ActorBehavior/fighter_skill.json`
|
||||||
|
- Create: `Client/Assets/Resources/Config/ActorBehavior/fighter_flyobj.json`
|
||||||
|
- Create: `Client/Assets/Resources/Config/ActorBehavior/fighter_buff.json`
|
||||||
|
- Modify: all new actor behavior files as needed after refactor
|
||||||
|
- Test: `Server/Framework.Shared.Tests/ActorBehavior/BehaviorConfigCatalogTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all previous public APIs.
|
||||||
|
- Produces: repository example JSON matching the spec.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing example config test**
|
||||||
|
|
||||||
|
Add to `BehaviorConfigCatalogTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
if (System.IO.Directory.Exists(System.IO.Path.Combine(dir, ".git")))
|
||||||
|
{
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
dir = System.IO.Directory.GetParent(dir)?.FullName;
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("repo root not found");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run example test to verify red**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj --filter ExampleConfigFiles_LoadFromRepository
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until example files exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add example JSON files**
|
||||||
|
|
||||||
|
Create the three example files using the JSON from `TestBehaviorJson`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run full shared tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Server\Framework.Shared.Tests\Framework.Shared.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run server solution build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build Server\Server.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Review git diff**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git diff -- Client/Assets/Framework/Shared/ActorBehavior Server/Framework.Shared.Tests/ActorBehavior Client/Assets/Resources/Config/ActorBehavior
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only actor behavior core, tests, and example config files changed.
|
||||||
Reference in New Issue
Block a user