主题
> 注意:本文档篇幅较长,部分 API 参考内容可能更适合放在 reference.md 中。
SCE Unit/技能系统游戏开发指南
AI 停步、技能不发动、Buff 数值或预警异常时,先读从配置到运行证据。区分树/扫描节点/移动节点/技能/效果的距离;默认扫描 500–1000 可覆盖,无攻击技能默认接近 200 不代表所有近战存在 140 的射程下限。模板只是配方,技能目标类型不与效果形状强制绑定。先确认接线、有效配置和当前对局证据,再调整参数。
前置知识:先阅读 wasicore-dev 了解编译配置和框架约束。
扩展已有 Unit 游戏时,可以保留数据、触发器和单位机制,仅对所需功能下钻。先通过 wasicore-game-architecture 中的桥接参考确认 Unit / Actor / Scene 对应的节点、运行端和控制归属;增加一个 Node 功能不要求重建项目。
本 skill 的目录名保留为 3d-unit-game,但它真正覆盖的是 SCE 场景 + Unit/Ability/Buff/AI/HP 路线。不要把“3D 游戏”自动等同于本 skill:直接创建 SceneGraph / Node 的 3D 游戏应先读 wasicore-game-architecture,再按需读 client-scenegraph-game 或 server-authoritative-3d-physics。
SCE Unit 游戏通常同时需要服务端和客户端代码,分别用 #if SERVER / #if CLIENT 包裹。SCE 场景可以是 Surface2D(2.5D 单层地表,仍可 3D 渲染)或 Volumetric3D(真 3D 多层空间);不要因为画面是 3D 就自动切到 Volumetric3D。
需要用 GameDataEffectLaunchMissile 发射自身实现冲锋,并在 Surface2D 悬崖、手绘格或装饰物静态碰撞处停止时,先读 dash-static-collision,核对复用单位、掩码、场景保存和收尾配置。
架构概览
服务端(权威) 客户端(表现)
├── GameDataUnit 定义(含攻击技能) ├── GameSystemUI 移动/技能摇杆
├── GameDataAbilityExecute 攻击技能 ├── HUD Canvas
├── Unit 创建与生命周期 ├── DualEndEvent 监听
├── player.MainUnit 设置 └── 单位自动同步(框架处理)
├── TacticalAI 自动战斗
├── DualEndEvent 发送
└── Vital 生命值管理单位由服务端创建后自动同步到客户端,无需手动同步位置/生命值。 GameSystemUI(项目已内置)提供平台自适应的移动摇杆和技能按钮,无需从零实现操控 UI。
重要约束:静态定义进表,动态数值改实例
图形化/数编项目的单位和技能优先继承 GameSparkCore 公共模板,流程见 data-editor。英雄、怪物、防御塔、召唤物和投射物应按自身语义选模板;不要为了补生命值而重复注册 Health/LifeMax。纯代码路线可继续使用官方基础 Link 定义自己的 GameData。
处理敌人、刷怪、波次成长时,默认遵循这条边界:
GameDataUnit、GameDataModel、GameDataAI等静态定义应以数编文件或统一的OnGameDataInitialization注册代码为准。- 不要在刷怪 trigger 中直接改
ScopeData.GameDataUnit.SomeUnit.Data这类共享定义,尤其不要临时补丁Model、TacticalAI、SyncType、默认属性。 - 按波次变化的生命、攻击、护甲、经验、出生后临时状态,应在
CreateUnit之后修改具体Unit实例。 - 如果确实需要运行时代码生成
GameData,优先创建独立的 runtime-only 条目;若必须补丁共享GameData,必须保证双端同路径初始化,而不是只在服务端懒修改。
重要约束:公式字段按可见性和偏向放置
写攻击、技能、效果树和 AI 行为树的 GameData 公式时,先看字段是否只存在于某一端 API,再看 [DataEditor(FunctionBias = ...)]:
GameDataAINodeValidateScan.Range这类只在服务端存在的字段,必须在#if SERVER中赋值。GameDataEffectXXX中带FunctionBias.Server的公式字段,默认可以只在#if SERVER中赋值;效果树本身通常只在服务端执行。- 技能
Range这类客户端可能用来显示 UI 的字段,如果公式是常量或双端共有 API,可以共享赋值,例如Range = static (_) => 500。 - 不要为了省事把所有 GameData 都包进
#if SERVER,客户端表现、Actor、UI 和显示数据仍需要客户端定义。
完整决策树见 docs/best-practices/GameDataFormulaPlacement.md。
完整最小示例
第 1 步:GameLink 和数据定义(共享代码)
csharp
using GameCore.AbilitySystem.Data.Struct;
using GameCore.EntitySystem.Data;
using GameCore.EntitySystem.Data.Enum;
using GameCore.Execution.Data.Enum;
using GameCore.Execution.Data.Struct;
using GameCore.BaseType;
using GameCore.ModelAnimation.Data;
using GameCore.PlayerAndUsers.Enum;
// gamesparkcore.ScopeData 已通过 csproj 全局别名为 GSC
// gamesystemui.ScopeData 已通过 csproj 全局别名为 GSUI
// 用法: GSC.GameDataUnitProperty.LifeMax, GSUI.GameDataControl.MoveControl
namespace MyGame;
// GameLink 声明(不包裹在 #if 中)
public static class MyLinks
{
// 单位
public static readonly GameLink<GameDataUnit, GameDataUnit> Hero = new("Hero"u8);
public static readonly GameLink<GameDataUnit, GameDataUnit> Enemy = new("Enemy"u8);
// 英雄攻击:Vector 目标 → 扇形搜索 → 伤害(允许空挥)
public static readonly GameLink<GameDataAbility, GameDataAbilityExecute> HeroAttack = new("HeroAttack"u8);
public static readonly GameLink<GameDataEffect, GameDataEffectSearch> HeroSearch = new("HeroSearch"u8);
public static readonly GameLink<GameDataEffect, GameDataEffectDamage> HeroDamage = new("HeroDamage"u8);
// 敌人攻击:Unit 目标 → 直接伤害(AI 控制,总有目标)
public static readonly GameLink<GameDataAbility, GameDataAbilityExecute> EnemyAttack = new("EnemyAttack"u8);
public static readonly GameLink<GameDataEffect, GameDataEffectDamage> EnemyDamage = new("EnemyDamage"u8);
// 攻击动画
public static readonly GameLink<GameDataAnimation, GameDataAnimationSimple> HeroAttackAnim = new("HeroAttackAnim"u8);
public static readonly GameLink<GameDataAnimation, GameDataAnimationSimple> EnemyAttackAnim = new("EnemyAttackAnim"u8);
}
public class ArenaGame : IGameClass
{
public static void OnRegisterGameClass()
{
Game.OnGameDataInitialization += OnGameDataInitialization;
}
private static void OnGameDataInitialization()
{
// ── 攻击技能定义 ──
// 英雄伤害效果:基于 AttackDamage 属性计算伤害
_ = new GameDataEffectDamage(MyLinks.HeroDamage)
{
Amount = static (context) =>
context.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackDamage) ?? 0,
Type = GSC.GameDataDamageType.Physical,
};
// 英雄搜索效果:以施法者为中心,扇形范围内搜索敌人,再对每个目标造成伤害
_ = new GameDataEffectSearch(MyLinks.HeroSearch)
{
Method = SearchMethod.Cone,
// Vector 技能的默认目标是方向末端坐标点,必须设为 Caster 使搜索以施法者为中心
TargetLocation = new() { Value = TargetLocation.Caster },
// 从施法者指向目标方向,避免施法者转身延迟导致扇形方向偏差
Facing = new()
{
Location = new() { Value = TargetLocation.Caster },
Method = EffectAngleMethod.AngleBetweenTwoPoints,
OtherLocation = new() { Value = TargetLocation.MainTarget },
},
Radius = static (ctx) =>
ctx.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackRange) ?? 0,
CentralAngle = static (_) => new Angle(120),
Effect = MyLinks.HeroDamage,
SearchFilters = [new()
{
Required = [UnitRelationship.Enemy, UnitFilter.Unit],
Excluded = [UnitState.Dead, UnitState.Invulnerable],
}],
SearchFlags = new SearchFlags { ExtendByUnitRadius = true },
};
// 攻击动画:短普攻要显式压低 BlendIn,避免高攻速时混入时间盖过出手动作
_ = new GameDataAnimationSimple(MyLinks.HeroAttackAnim)
{
File = "attack_01"u8,
Priority = 30,
BlendIn = TimeSpan.Zero,
};
_ = new GameDataAnimationSimple(MyLinks.EnemyAttackAnim)
{
File = "attack_01"u8,
Priority = 30,
BlendIn = TimeSpan.Zero,
};
// 动画 Priority 有效范围是 0-31;低于 0 按 0 处理,高于 31 按 31 处理;普攻/技能动画通常使用较高值,例如 30
// 英雄攻击技能:Vector 目标,允许空挥(周围没敌人时也能挥砍)
// AlwaysAcquireTarget = true 使技能自动面向最近的敌人
_ = new GameDataAbilityExecute(MyLinks.HeroAttack)
{
DisplayName = "攻击",
Time = new()
{
Preswing = static (_) => TimeSpan.FromSeconds(0.4),
Channel = static (_) => TimeSpan.FromSeconds(0.6),
Backswing = static (_) => TimeSpan.FromSeconds(0.3),
NormalizedDuration = static (_) => TimeSpan.FromSeconds(1.0),
},
Effect = MyLinks.HeroSearch,
TargetType = AbilityTargetType.Vector,
Range = static (e) =>
(float)(e.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackRange) ?? 0),
AbilityExecuteFlags = new() { IsAttack = true, AlwaysAcquireTarget = true },
Animation = [MyLinks.HeroAttackAnim],
AcquireSettings = new()
{
Recast = true,
TargetingFilters = [new()
{
Required = [UnitRelationship.Enemy],
Excluded = [UnitState.Dead, UnitState.Invulnerable],
}],
},
};
// 敌人伤害效果
_ = new GameDataEffectDamage(MyLinks.EnemyDamage)
{
Amount = static (context) =>
context.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackDamage) ?? 0,
Type = GSC.GameDataDamageType.Physical,
};
// 敌人攻击技能
_ = new GameDataAbilityExecute(MyLinks.EnemyAttack)
{
DisplayName = "攻击",
Time = new()
{
Preswing = static (_) => TimeSpan.FromSeconds(0.5),
Channel = static (_) => TimeSpan.FromSeconds(0.7),
Backswing = static (_) => TimeSpan.FromSeconds(0.3),
NormalizedDuration = static (_) => TimeSpan.FromSeconds(1.2),
},
Effect = MyLinks.EnemyDamage,
TargetType = AbilityTargetType.Unit,
Range = static (e) =>
(float)(e.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackRange) ?? 0),
AbilityExecuteFlags = new() { IsAttack = true },
Animation = [MyLinks.EnemyAttackAnim],
AcquireSettings = new()
{
Recast = true,
TargetingFilters = [new()
{
Required = [UnitRelationship.Enemy],
Excluded = [UnitState.Dead, UnitState.Invulnerable],
}],
},
};
// ── 单位定义 ──
// 英雄单位
_ = new GameDataUnit(MyLinks.Hero)
{
CollisionRadius = 40f,
AttackableRadius = 50f,
Filter = [UnitFilter.Unit, UnitFilter.Hero],
Leveling = GSC.GameDataUnitLeveling.HeroLevelingSample,
Level = 1,
UpdateFlags = new UpdateFlags
{
Walkable = true,
Turnable = true,
AllowMover = true,
},
Properties = new UnitPropertyInitialData
{
{ GSC.GameDataUnitProperty.LifeMax, 500 },
{ GSC.GameDataUnitProperty.MoveSpeed, 300 },
{ GSC.GameDataUnitProperty.TurningSpeed, 720 },
{ GSC.GameDataUnitProperty.AttackDamage, 25 },
{ GSC.GameDataUnitProperty.Armor, 5 },
{ GSC.GameDataUnitProperty.Sight, 1200 },
{ GSC.GameDataUnitProperty.AttackRange, 150 },
},
VitalProperties = [GSC.GameDataVital.Health],
PrimitiveShape = new PrimitiveShapeConfig
{
Shape = PrimitiveShape.Capsule,
Scale = new Vector3(0.5f, 0.5f, 1f),
},
Abilities = [MyLinks.HeroAttack],
StatusBarSetting = new()
{
DefaultStatusBar = GSC.GameDataStatusBar.AllyHeroNone,
OverrideByRelationShip = new()
{
{ PlayerUnitRelationShip.MainUnit, GSC.GameDataStatusBar.MainHeroNone },
{ PlayerUnitRelationShip.Alliance, GSC.GameDataStatusBar.AllyHeroNone },
{ PlayerUnitRelationShip.Enemy, GSC.GameDataStatusBar.EnemyHeroNone },
},
},
};
// 敌人单位(这里示例使用默认 TacticalAI 预设;也可以替换为自定义 AIThinkTree)
_ = new GameDataUnit(MyLinks.Enemy)
{
CollisionRadius = 30f,
AttackableRadius = 40f,
Filter = [UnitFilter.Unit],
TacticalAI = GSC.GameDataAIThinkTree.Default,
DeathRemovalDelay = TimeSpan.FromSeconds(3),
UpdateFlags = new UpdateFlags
{
Walkable = true,
Turnable = true,
AllowMover = true,
},
Properties = new UnitPropertyInitialData
{
{ GSC.GameDataUnitProperty.LifeMax, 200 },
{ GSC.GameDataUnitProperty.MoveSpeed, 200 },
{ GSC.GameDataUnitProperty.TurningSpeed, 720 },
{ GSC.GameDataUnitProperty.AttackDamage, 10 },
{ GSC.GameDataUnitProperty.Sight, 800 },
{ GSC.GameDataUnitProperty.AttackRange, 100 },
},
VitalProperties = [GSC.GameDataVital.Health],
PrimitiveShape = new PrimitiveShapeConfig
{
Shape = PrimitiveShape.Sphere,
Scale = new Vector3(0.6f, 0.6f, 0.6f),
},
Abilities = [MyLinks.EnemyAttack],
StatusBarSetting = new()
{
DefaultStatusBar = GSC.GameDataStatusBar.EnemyNormalNone,
OverrideByRelationShip = new()
{
{ PlayerUnitRelationShip.Alliance, GSC.GameDataStatusBar.AllyNormalNone },
{ PlayerUnitRelationShip.Enemy, GSC.GameDataStatusBar.EnemyNormalNone },
},
},
};
// 不需要创建 GameDataGameMode —— 项目已有 MapGameMode(含场景、玩家配置)
// 不需要修改 GlobalConfig.cs —— MapGameMode 已注册为默认测试模式
}
}第 2 步:DualEndEvent 定义(共享代码)
csharp
// TriggerEncapsulation 已通过 csproj 全局 using 导入,无需手动 using
namespace MyGame;
// 服务端→客户端的游戏状态通知
public class GameStateEvent : IDualEndEvent<GameStateEvent>
{
public Player? Sender { get; set; }
public int WaveNumber { get; set; }
public int EnemiesAlive { get; set; }
public string Message { get; set; } = "";
}第 3 步:服务端逻辑
csharp
#if SERVER
using GameCore.EntitySystem;
using GameCore.GameSystem;
using GameCore.SceneSystem;
using GameCore.VitalSystem;
using GameCore.BaseType;
// GSC = gamesparkcore.ScopeData(全局别名,无需手动 using)
namespace MyGame;
public class ArenaServer : IGameClass
{
private static Scene? scene;
private static readonly List<Unit> enemies = [];
private static int waveNumber;
public static void OnRegisterGameClass()
{
Game.OnGameTriggerInitialization += OnGameTriggerInitialization;
}
private static void OnGameTriggerInitialization()
{
Game.Subscribe<EventGameStart>(async (s, d) =>
{
await StartGame();
});
}
private static async Task StartGame()
{
// 加载项目中已有的场景(每个项目都有 new_scene 默认场景)
scene = Scene.GetOrCreate(GameEntry.ScopeData.GameDataScene.new_scene);
scene.Load();
var center = new ScenePoint(2048, 2048, scene);
// 为每个真人玩家创建英雄(跳过中立玩家)
foreach (var player in Player.AllPlayers)
{
if (player.IsNeutral) continue;
var hero = MyLinks.Hero.Data?.CreateUnit(player, center, new Angle(0));
if (hero == null) continue;
player.MainUnit = hero; // 设置主控单位(客户端自动跟随相机 + 启用操控)
Game.Logger.LogInformation("为玩家 {PlayerId} 创建英雄", player.Id);
}
// 开始刷怪循环
await SpawnWaveLoop();
}
private static async Task SpawnWaveLoop()
{
while (true)
{
waveNumber++;
int count = 3 + waveNumber * 2;
SpawnEnemies(count);
// 通知客户端
DualEndEvent.Publish(new GameStateEvent
{
WaveNumber = waveNumber,
EnemiesAlive = enemies.Count,
Message = $"第 {waveNumber} 波!",
});
// 等待所有敌人被消灭
while (enemies.Count > 0)
{
enemies.RemoveAll(e => !e.IsAlive);
await Game.Delay(500);
}
DualEndEvent.Publish(new GameStateEvent
{
WaveNumber = waveNumber,
EnemiesAlive = 0,
Message = "波次完成!",
});
await Game.Delay(3000);
}
}
private static void SpawnEnemies(int count)
{
if (scene == null) return;
// 敌方玩家:查看项目 PlayerSettings.cs 中的队伍配置确定 ID。
// 默认模版:队伍0=中立(玩家0)、队伍1(玩家1/2)、队伍2(玩家3/4)。
// 中立玩家(ID=0)的单位永远不会被判定为敌人。
var enemyPlayer = Player.GetById(3);
var random = new Random();
for (int i = 0; i < count; i++)
{
float x = 512 + random.Next(3072);
float y = 512 + random.Next(3072);
var pos = new ScenePoint(x, y, scene);
var enemy = MyLinks.Enemy.Data?.CreateUnit(enemyPlayer, pos, new Angle(0), useDefaultAI: true);
if (enemy != null) enemies.Add(enemy);
}
}
}
#endifSCE Scene 与 GameGraph
角色传送、副本往返或切场景后主控/血蓝/技能/镜头异常时,先读 角色跨场景迁移,明确英雄迁移、玩家视图切换和主控绑定的职责,并分别验收服务端与客户端。
Scene.GetOrCreate(...).Load() 加载的是框架管理的 SCE 数编场景。普通联机流程里每个客户端同一时刻最多绑定一个当前 SCE 场景;它的加载、切换、卸载由框架场景系统负责,不要对它的内置 GameGraph SceneGraph 手动 Publish、Unpublish 或 Dispose。
GameDataScene.SpaceModel 只决定寻路、单位高度和碰撞的空间语义,不决定场景是不是 3D 画面。SceneSpaceModel.Surface2D 是 2.5D 单层地表寻路:SCE 场景仍然可以有地形高度、坡面、高台和 Z 坐标;不要把它理解为 Canvas/纯 2D 场景或无高度场景。只有同一 XY 位置需要桥、隧道、上下层平台等多层可走面时,才需要 SceneSpaceModel.Volumetric3D。
新建 3D 项目/场景、选择 3D 模板、配置 SpaceModel、生成 3D 体素/导航/碰撞产物的编辑器流程,先看 ../../../guides/3DSceneSetup.md。不要只在代码里改 GameDataScene.SpaceModel 后就假设 3D 导航产物已经存在。
需要在当前 SCE 场景里访问或挂载 GameGraph 节点时,引入 using GameGraph.NodeSystem;:
csharp
var sceneGraph = scene.GetSceneGraph();
var syncRoot = scene.GetSyncRoot();
scene.AddSyncNode(node, NodeReplication.Replicated);SCE 场景中的普通 replicated Node 默认随当前 Scene 同步;如果它是某个 Unit 的 hitbox、hurtbox、fire point、raycast target 或自定义 proxy,应通过 BindNode(...) / EnsureNodeBinding(...) 建立 UnitNodeBinding。绑定后的 replicated Node 会随该 Unit 的服务端视野可见性对客户端创建/移除,覆盖 UnitDriven、NodeDriven 和 Manual 模式;未绑定 Unit 的 Node 不会按自身坐标自动套用单位视野。
3D 单位移动控制器 / PhysicalCharacter
如果目标是“角色单位在 3D 场景里跑、跳、上下平台、播放跑跳落地动画”,优先使用 GameDataUnit.MovementController 与 UnitMovementController,不要直接手写 SceneGraph node 驱动 Unit。
- 普通 3D 寻路:
UnitMovementMode.VolumetricNavigation。 - 物理角色手感:
UnitMovementMode.PhysicalCharacter,底层使用KinematicCharacterMotor胶囊角色。 - 切换模式、跳跃、额外空中跳必须由服务端权威执行;客户端只发请求和显示状态。
- KCC 偶发着地或阻挡问题使用 Runtime MCP
debug.overlay订阅MotorProbe,再读取motor.snapshot的连续服务端物理帧;不要用单张截图或项目自定义向下射线代替 motor 证据。 Rigidbody/Scripted单位模式目前是保留项,不要作为公开推荐入口。- 完整说明见
../../../systems/UnitMovementControllerSystem.md。
常见数据入口:
csharp
_ = new GameDataUnit(MyLinks.Hero)
{
UpdateFlags = new UpdateFlags
{
Walkable = true,
Turnable = true,
AllowMover = true,
},
MovementController = GSC.GameDataUnitMovementController.Generic3DMovementController,
Properties = new UnitPropertyInitialData
{
{ GSC.GameDataUnitProperty.MoveSpeed, 300 },
{ GSC.GameDataUnitProperty.JumpLaunchSpeed, 650 },
{ GSC.GameDataUnitProperty.ExtraAirJumps, 1 },
},
};常见运行时入口:
csharp
#if SERVER
using TriggerEncapsulation;
unit.ActivateUnitPhysicalCharacterMovement();
unit.TryJumpUnit();
unit.SetUnitExtraAirJumpCount(1);
unit.ReturnUnitToSceneNavigation();
#endif
var signal = unit.GetUnitMovementSignal();Runtime MCP 诊断顺序:
- 先用
runtime_list_targets选择目标 Gameplay;多人调试必须传同一个target_id。 - 调用
debug.overlay,参数使用mode=MotorProbe,按需指定服务端unit_id和 33 到 1000 毫秒的snapshot_interval_ms。 - 调用
motor.snapshot读取grounded、selected_ground、velocity、forward_sweep、penetration_contact等连续帧;增量轮询传上次的physics_frame给after_physics_frame。 motor_target_missing/motor_not_physical_character如果带classification_advisory=true,只是客户端复制状态提示,必须重试或结合服务端帧判断。- 固定静态盒、接缝、台阶与碰撞根替换应分别复现;不要先泛化修改 KCC grounded 规则。
要做 idle / move / jump / fall / land 动画,用 GameDataUnitLocomotionAnimationProfile 挂到 GameDataModel.UnitLocomotionAnimationProfile,不要只依赖默认 idle / move 别名。
服务端 new SceneGraph(SceneGraphReplication.ReplicatedUnpublished) 创建的是独立发布图,可按玩家 Publish(player);同一个客户端可在当前 SCE 场景之外同时订阅多个发布图。客户端 SceneGraph.OnRegistered / GetRegisteredSceneGraphs() 面向这些额外发布图,不是获取当前 SCE 场景的入口。
发布图注册不等于自动渲染。独立 SceneGraph 可以只作为同步数据源使用,例如客户端读取节点状态后绘制到 Canvas/UI,而不创建 Camera 或接管 Viewport。若要直接显示为 3D 画面,客户端需要配置 Octree、local 表现组件、CameraComponent 和 Viewport;底层入口是 Renderer.SetupMainViewport(...),SceneGraphMainViewportController 只是可选 helper。
非 Unit 的服务端权威 3D 物理
如果玩法对象不是 Unit,但仍需要服务端权威 3D 物理,默认使用 GameGraph 发布图模式:服务端创建 replicated Node,本地 PhysicsWorld / RigidBody 推动物理,客户端只接收 transform 并补本地模型、材质、粒子、音效。静态障碍优先双端 local;动态、会影响玩法判定、或需要按玩家隔离可见性的物体再放入 SceneGraphReplication.ReplicatedUnpublished 并 Publish(player)。
完整模式见 ../server-authoritative-3d-physics/SKILL.md。不要为了非 Unit 物理对象强行套 Unit / Actor;Unit 仍适合角色、怪物、目标过滤、生命值、技能、AI 等框架玩法实体。
第 4 步:客户端操控 + HUD
csharp
#if CLIENT
// GameUI.Control.Extensions 已通过 csproj 全局 using 导入
// GameSystemUI.AbilitySystemUI.Advanced 已通过 csproj 全局 using 导入
namespace MyGame;
public class ArenaClient : IGameClass
{
private static Label? waveLabel;
private static Label? enemyLabel;
private static Label? statusLabel;
public static void OnRegisterGameClass()
{
Game.OnGameTriggerInitialization += OnGameTriggerInitialization;
}
private static void OnGameTriggerInitialization()
{
Game.Subscribe<GameStateEvent>(async (s, d) =>
{
if (waveLabel != null) waveLabel.Text = $"波次: {d.WaveNumber}";
if (enemyLabel != null) enemyLabel.Text = $"剩余敌人: {d.EnemiesAlive}";
if (!string.IsNullOrEmpty(d.Message) && statusLabel != null)
statusLabel.Text = d.Message;
});
Game.Subscribe<EventGameStart>(async (s, d) =>
{
InitializeHUD();
await InitializeControls();
});
}
private static void InitializeHUD()
{
waveLabel = UI.Label("波次: 0").FontSize(20).TextColor(Color.White);
enemyLabel = UI.Label("剩余敌人: 0").FontSize(20).TextColor(Color.White);
statusLabel = UI.Label("等待游戏开始...").FontSize(16).TextColor(Color.FromArgb(255, 255, 220, 100));
UI.VStack(5, waveLabel, enemyLabel, statusLabel)
.Size(280, 90).AlignLeft().AlignTop().Margin(10)
.Background(Color.FromArgb(160, 0, 0, 0)).Padding(10)
.AddToRoot();
}
private static async Task InitializeControls()
{
for (int i = 0; i < 20; i++)
{
if (Player.LocalPlayer?.MainUnit != null) break;
await Game.Delay(500);
}
var mainUnit = Player.LocalPlayer?.MainUnit;
if (mainUnit == null) return;
var moveControl = GSUI.GameDataControl.MoveControl.Data!.CreateControl();
moveControl.AddToVisualTree();
var abilityGroup = new AbilityJoyStickGroup(GSUI.GameDataControl.DefaultAbilityJoyStickGroup)
{
BindUnit = mainUnit,
};
abilityGroup.AddToRoot();
}
}
#endif关键 API 速查
攻击技能(GameDataAbilityExecute)
单位必须配置攻击技能才能攻击。没有技能 = 英雄没有攻击按钮,TacticalAI 也无法执行攻击。
csharp
// 1. 定义伤害效果
_ = new GameDataEffectDamage(damageLink)
{
Amount = static (ctx) => ctx.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackDamage) ?? 0,
Type = GSC.GameDataDamageType.Physical,
};
// 2. 定义攻击技能(IsAttack = true 标记为普攻,显示在技能摇杆上)
_ = new GameDataAbilityExecute(attackLink)
{
DisplayName = "攻击", // 不设则技能按钮显示 GameLink 原始 ID
Effect = damageLink,
TargetType = AbilityTargetType.Unit,
Range = static (e) => (float)(e.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackRange) ?? 0),
AbilityExecuteFlags = new() { IsAttack = true },
Time = new()
{
Preswing = static (_) => TimeSpan.FromSeconds(0.4), // 攻击前摇
Channel = static (_) => TimeSpan.FromSeconds(0.6), // 攻击锁定/持续阶段
Backswing = static (_) => TimeSpan.FromSeconds(0.3), // 攻击后摇
NormalizedDuration = static (_) => TimeSpan.FromSeconds(1.0), // 目标普攻周期
},
// 必须配置目标过滤!空过滤器放行所有目标(含自身),且 Unit 技能会校验目标合法性
AcquireSettings = new()
{
Recast = true, // 需要连续普攻时开启;只想一次指令打一刀则保持 false
TargetingFilters = [new()
{
Required = [UnitRelationship.Enemy],
Excluded = [UnitState.Dead, UnitState.Invulnerable],
}],
},
};
// 3. 将技能绑定到单位
_ = new GameDataUnit(unitLink) { Abilities = [attackLink], /* ... */ };> AcquireSettings.TargetingFilters 是所有攻击技能的必需配置。TargetingFilters 为空时放行所有目标(含自身)——SmartAcquire 自动选取会选中自身(默认按距离排序,自身距离为 0;可通过 AcquireSorting 自定义排序),Unit 目标技能的手动目标校验也会接受自身为合法目标,导致单位攻击自己。详见 技能系统 — AcquireSettings。
> IsAttack 和 AcquireSettings.Recast 分工不同。 IsAttack = true 表示这个技能可以被 Attack 指令、AttackTarget 和默认战斗 AI 当作普攻技能使用;不设置时单位即使拥有这个技能,也不会被攻击指令自动选中。AcquireSettings.Recast = true 表示一次施法完成后继续尝试下发下一次同技能指令,用于按住攻击、自动施法或 AI 连续普攻。只需要单次攻击的技能可以不打开 Recast。
> 精确控制普攻速度时,用 Time.NormalizedDuration,不要把 Cost.Cooldown 当作普攻间隔。 Cooldown 是技能消耗/可用性门槛,作为 Cost 通常在进入 Channel 并消耗资源时启动;下一次攻击至少还要经历前摇、施法/引导等阶段。若把攻速公式只写在冷却上,施法时间仍会卡住攻速。需要固定或动态攻速的普攻,通常不配置 Cost.Cooldown,而是在 NormalizedDuration 中写目标攻击周期;有攻速属性时,把攻速换算公式放到 NormalizedDuration。
> 短普攻动画要单独配置 GameDataAnimationSimple.BlendIn。 NormalizedDuration 只控制技能阶段时长,并让技能动画按总施法时间调速;它不会自动缩短动画混入时间。GameDataAnimationSimple.BlendIn 默认约 0.15 秒,如果普攻周期可能接近或短于这个时间,剑还没完全混入到抬手姿态就会被下一次攻击压掉。短攻击动画建议在动画数据上写 BlendIn = TimeSpan.Zero,或按项目手感设置为 20-50ms。
英雄普攻目标类型选择(Unit vs Vector)
英雄的普攻技能是用 Unit 还是 Vector 目标类型,取决于游戏类型:
| 目标类型 | 行为 | 适合 |
|---|---|---|
Unit | 必须有合法目标才能攻击;无目标时按钮无效 | MOBA、RTS(敌人密集,总有目标) |
Vector | 朝面朝方向挥砍,无目标也能空挥;搜索范围内有敌人则造成伤害 | 生存、ARPG、动作游戏(玩家随时可攻击) |
> 推荐:如果游戏允许玩家在没有敌人时攻击(空挥),英雄普攻应使用 Vector + EffectSearch + EffectDamage(见上方主示例)。敌人由 AI 控制、总有目标,用 Unit 即可。
效果链配方(AbilityTargetType → Effect)
AbilityTargetType 决定了技能的施法目标类型,也决定了传给效果链的主目标的性质。不同目标类型需要不同的效果链结构:
| AbilityTargetType | 主目标性质 | 典型效果链 | 适用场景 |
|---|---|---|---|
Unit | 单位实体 | Ability → EffectDamage | 单体攻击(最常见) |
Vector | 方向末端坐标点 | Ability → EffectSearch(Cone) → EffectDamage | 扇形挥砍、剑气 |
Ground | 地面坐标点 | Ability → EffectSearch(Circle) → EffectDamage | AOE 技能、轰炸 |
None | 施法者自身 | Ability → EffectSearch(Circle, center=Caster) → EffectDamage | 新星、战吼 |
关键规则:GameDataEffectDamage 只能作用于单位目标。如果技能的主目标是坐标点(Vector/Ground/None),必须先用 GameDataEffectSearch 搜索范围内的单位,再对搜索到的每个单位执行 EffectDamage。
单体攻击(Unit 目标,最常见)
csharp
// 主目标就是单位,直接造伤害
_ = new GameDataAbilityExecute(attackLink)
{
TargetType = AbilityTargetType.Unit,
Effect = damageLink, // 直接指向 EffectDamage
// ...
};扇形范围攻击(Vector 目标)
csharp
using GameCore.Execution.Data.Enum;
using GameCore.Execution.Data.Struct;
// 1. 搜索效果:以施法者为中心,扇形范围内找到敌人
_ = new GameDataEffectSearch(searchLink)
{
Method = SearchMethod.Cone,
// Vector 技能的默认主目标是方向末端坐标点,必须设为 Caster 使搜索以施法者为中心
TargetLocation = new() { Value = TargetLocation.Caster },
// 显式设为从施法者到主目标(方向末端)的朝向,避免施法者转身延迟导致扇形方向偏差
Facing = new()
{
Location = new() { Value = TargetLocation.Caster },
Method = EffectAngleMethod.AngleBetweenTwoPoints,
OtherLocation = new() { Value = TargetLocation.MainTarget },
},
Radius = static (ctx) =>
ctx.Caster.GetUnitPropertyFinal(GSC.GameDataUnitProperty.AttackRange) ?? 0,
CentralAngle = static (_) => new Angle(120),
Effect = damageLink, // 对每个搜索到的单位执行伤害
SearchFilters = [new()
{
Required = [UnitRelationship.Enemy, UnitFilter.Unit],
Excluded = [UnitState.Dead, UnitState.Invulnerable],
}],
// 搜索半径额外扩大施法者的 InteractRadius,与指令系统的接近判定一致
SearchFlags = new SearchFlags { ExtendByUnitRadius = true },
};
// 2. 技能指向搜索效果(不是直接指向伤害)
_ = new GameDataAbilityExecute(attackLink)
{
TargetType = AbilityTargetType.Vector,
Effect = searchLink, // 指向 EffectSearch,不是 EffectDamage
AbilityExecuteFlags = new() { IsAttack = true, AlwaysAcquireTarget = true },
// ...
};> AlwaysAcquireTarget = true 使 Vector 技能自动面向最近的敌人,无需玩家手动选择方向。TargetLocation = Caster 使搜索以施法者为中心(Vector 默认主目标是方向末端坐标点)。ExtendByUnitRadius = true 将搜索半径额外扩大施法者的 InteractRadius,与指令系统的接近判定(Range + InteractRadius)一致。
圆形范围攻击(Ground 目标)
csharp
_ = new GameDataEffectSearch(searchLink)
{
Method = SearchMethod.Circle,
Radius = static (_) => 300,
Effect = damageLink,
SearchFilters = [new()
{
Required = [UnitRelationship.Enemy, UnitFilter.Unit],
Excluded = [UnitState.Dead, UnitState.Invulnerable],
}],
};
_ = new GameDataAbilityExecute(aoeLink)
{
TargetType = AbilityTargetType.Ground,
Effect = searchLink,
Range = static (_) => 800, // 施法距离
// ...
};状态栏(StatusBarSetting)
单位默认没有血条。配置 StatusBarSetting 后才会显示。
Hero 系列状态栏左侧有等级槽。这个槽位读取单位等级;如果单位没有 Level,也没有 Leveling/UnitLeveling 数据,左侧会显示一块黑色区域。不要把它当成贴图缺失处理。
- 不想显示等级:使用 Normal 系列状态栏。
PlayerUnitRelationShip.MainUnit没有MainNormal*预设,主控单位也可以显式指向AllyNormalNone/AllyNormalMana/AllyNormalExp等 Normal 变体。 - 想显示等级:使用 Hero 系列状态栏,并同时配置
Leveling = GSC.GameDataUnitLeveling.HeroLevelingSample和Level = 1。
csharp
_ = new GameDataUnit(heroLink)
{
// 使用 Hero 变体需要配置 Leveling,否则等级槽位显示黑块
Leveling = GSC.GameDataUnitLeveling.HeroLevelingSample,
Level = 1,
StatusBarSetting = new()
{
DefaultStatusBar = GSC.GameDataStatusBar.AllyHeroNone,
OverrideByRelationShip = new()
{
{ PlayerUnitRelationShip.MainUnit, GSC.GameDataStatusBar.MainHeroNone },
{ PlayerUnitRelationShip.Alliance, GSC.GameDataStatusBar.AllyHeroNone },
{ PlayerUnitRelationShip.Enemy, GSC.GameDataStatusBar.EnemyHeroNone },
},
},
};| 变体 | 前缀 | 说明 |
|---|---|---|
| Normal | AllyNormalNone / AllyNormalMana / AllyNormalExp;EnemyNormalNone / EnemyNormalMana / EnemyNormalExp;NeutralNormalNone / NeutralNormalMana / NeutralNormalExp | 无等级槽位,适合小兵/敌人,或不想显示等级的主控单位 |
| Hero | MainHeroNone / MainHeroMana / MainHeroExp / MainHeroEnergy / MainHeroAnger;AllyHeroNone / AllyHeroMana / AllyHeroExp / AllyHeroEnergy / AllyHeroAnger;EnemyHeroNone / EnemyHeroMana / EnemyHeroExp / EnemyHeroEnergy / EnemyHeroAnger;NeutralHeroNone / NeutralHeroMana / NeutralHeroExp / NeutralHeroEnergy / NeutralHeroAnger | 含等级槽位,必须配合 Leveling 和有效 Level |
AI 两层职责速记
概念定义以 AI 系统 为准;这里给出做游戏时最常用的判断方式。
| 想解决什么问题 | 优先看什么 | 说明 |
|---|---|---|
| 单个敌人会不会自动寻敌、攻击、施法 | TacticalAI / AIThinkTree | GameDataUnit.TacticalAI 是配置字段,运行时组件名叫 AIThinkTree |
| 一波敌人往哪走、跟随谁、如何主动追玩家 | WaveAI | 负责巡逻、跟随、追杀等宏观移动 |
| 怪物会巡逻/追人但不攻击 | useDefaultAI / enableCombat | 先查硬开关,再查 scan range、MinimalApproachRange、HysteresisFactor(Follow)/ ReacquireRangeFactor(Hunt) |
AI 行为(TacticalAI)
这里的 TacticalAI 指 GameDataUnit.TacticalAI 配置字段;它引用的数编类型是 GameDataAIThinkTree,运行时组件名叫 AIThinkTree。GSC.GameDataAIThinkTree.Default 是框架提供的默认预设,但不是唯一合法取值。
如果你采用框架默认的自动寻敌 / 自动攻击方案,通常需要同时确认下面四点:
TacticalAI指向一个可用的GameDataAIThinkTree(最常用的是GSC.GameDataAIThinkTree.Default,也可以是自定义战斗思考树)Abilities中有IsAttack = true的技能(默认 AI 用它作为攻击能力)- 创建时传入
useDefaultAI: true,或在创建后手动调用AIThinkTree.AddDefaultAI(unit)/myAIThinkTreeLink.Data!.CreateAI(unit)挂载战斗 AI - 目标单位的
Filter命中当前 AI 的ScanFilters(默认 AI 攻击普通战斗单位时通常要求UnitFilter.Unit)
如果你使用自定义 AIThinkTree,是否必须有 IsAttack 技能、目标需要哪些 Filter,要以你的行为树和 ScanFilters 设计为准。
useDefaultAI 只是创建时自动挂载 GameDataUnit.TacticalAI 的便捷入口,不是唯一入口。单位创建出来后,仍然可以在服务端动态添加或替换战斗 AI:
csharp
#if SERVER
var enemy = enemyLink.Data?.CreateUnit(player, pos, facing); // 不自动挂 AI
if (enemy is not null)
{
AIThinkTree.AddDefaultAI(enemy); // 方案 A:按 GameDataUnit.TacticalAI 自动创建
// MyLinks.CustomAI.Data!.CreateAI(enemy); // 方案 B:显式挂自定义 AI,会替换已有 AI
}
#endif> 排查顺序:如果怪物“会巡逻/追人但不攻击”,先查单位最终有没有挂上 AIThinkTree(CreateUnit(..., useDefaultAI: true)、AIThinkTree.AddDefaultAI(unit)、myAIThinkTreeLink.Data!.CreateAI(unit) 三者至少走过一条),再查 WaveAI 是否被 StartWaveAI(..., enableCombat: false)、SetCombatEnabled(false) 或 SetRoute(..., false) 关掉了战斗。
> 这两个是硬开关,优先级高于 MinimalApproachRange、HysteresisFactor(Follow)/ ReacquireRangeFactor(Hunt)、MinimalScanRange 这类调参项。
csharp
// 数据定义
_ = new GameDataUnit(enemyLink)
{
Filter = [UnitFilter.Unit],
TacticalAI = GSC.GameDataAIThinkTree.Default, // 这里使用框架默认预设;也可换成自定义 AIThinkTree
Abilities = [enemyAttackLink], // 必须有 IsAttack 技能
// ...
};
// 创建时按 TacticalAI 自动挂载 AI
var enemy = enemyLink.Data?.CreateUnit(player, pos, facing, useDefaultAI: true);> Filter 是 AI 检测目标的前提。使用默认自动攻击方案时,即使 TacticalAI、IsAttack、useDefaultAI 都已正确配置,如果目标单位的 Filter 不包含 UnitFilter.Unit,AI 仍然找不到可攻击目标。若使用自定义 AIThinkTree,则以自定义 ScanFilters 为准。
| 配置项 | 说明 |
|---|---|
TacticalAI = GSC.GameDataAIThinkTree.Default | 使用框架内置默认战斗 AI 预设(推荐起步) |
TacticalAI = MyLinks.CustomAI | 使用自定义 AIThinkTree(按自定义行为树 / ScanFilters 工作) |
TacticalAI = null(默认) | 无 AI,单位不主动行动 |
useDefaultAI: true | CreateUnit 时按 GameDataUnit.TacticalAI 自动挂载 AI;不传也可以后续手动 AddDefaultAI/CreateAI |
Filter = [UnitFilter.Unit] | 使用默认 AI 攻击普通战斗单位时,目标通常需要此标签 |
DeathRemovalDelay = TimeSpan.FromSeconds(3) | 死亡后 3 秒移除尸体(不设则立即消失) |
敌人群体 AI(WaveAI)
TacticalAI 只控制单体战斗(扫描范围内寻敌攻击)。如果需要敌人主动追向玩家(而非等玩家进入扫描范围),需要配合 WaveAI 群体 AI 系统。
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 敌人守在刷新位置附近 | 仅 TacticalAI | 默认行为,扫描范围内寻敌 |
| 生存游戏:敌人全图追杀玩家 | GameDataWaveAIHunt(追杀模式) | 每个怪独立追杀距自己最近的敌方目标 |
| 敌人跟随首领/玩家 | GameDataWaveAIFollow(跟随模式) | 所有成员追向同一个目标 |
| 敌人沿路线行进 | GameDataWaveAIPatrol(巡逻模式) | 路线巡逻,遇敌战斗后可恢复巡逻 |
> 重要:WaveAI 只负责宏观移动,不会替代 TacticalAI 完成战斗。
> 如果创建单位时用了 useDefaultAI: false,而且后续也没有手动 AddDefaultAI/CreateAI 挂战斗 AI,或者在 WaveAI 上覆盖了 enableCombat: false,典型表现就是“怪物会走,但不出手”。
追杀模式示例(生存类游戏推荐)
csharp
using GameCore.AISystem.Data;
namespace MyGame;
public static class MyLinks
{
// ... 其他 GameLink ...
public static readonly GameLink<GameDataWaveAI, GameDataWaveAIHunt> MonsterHunt = new("MonsterHunt"u8);
}数据定义(共享代码,不包裹在 #if 中):
csharp
_ = new GameDataWaveAIHunt(MyLinks.MonsterHunt)
{
EnableCombat = true,
MinimalScanRange = 500,
MaximalScanRange = 2000,
};服务端使用:
csharp
#if SERVER
// 刷怪后用 WaveAI 管理整波敌人
var waveAI = MyLinks.MonsterHunt.Data!.CreateWaveAI();
foreach (var enemy in newEnemies) waveAI.Add(enemy);
waveAI.StartThinking();
// 无需其他设置:每个怪自动追杀距自己最近的敌方目标
// 目标死亡后自动切换到下一个最近目标
// 监听全灭事件
waveAI.OnWaveEliminated += (wave) =>
{
Game.Logger.LogInformation("波次全灭");
};
#endif工作原理:WaveAI 每 2 秒思考一次,遍历敌方 Player.Units 找到距该成员最近的目标,驱动成员移动接近。接近目标后自动交给 TacticalAI 处理战斗(滞后控制机制防止边界震荡)。
> 不要用 AttackTarget 轮询替代 WaveAI。周期性对所有敌人调用 AttackTarget 会导致性能浪费和行为中断。WaveAI 利用框架内置的 Thinker 调度机制,自动分散思考负载。
> 调参提示:Hunt 的追击停止距离由 MinimalApproachRange 配置,交战交接阈值还会考虑有效扫描范围。AIThinkTree.ScanRange 优先使用树上的公式;未配置时,有攻击技能则取 Clamp(Attack.Range, MinimalScanRange, MaximalScanRange),无攻击技能则取 MinimalScanRange。扫描节点的 Range 还能单独覆盖战斗内搜敌范围。默认扫描上下限为 500 和 1000,均可配置;Sight 用于视野和战争迷雾。遇到接管后停步,核对实际扫描范围、移动节点和技能 Range,参见距离来源与运行诊断。
如果项目引用了 GameSparkCore,可以直接使用它内置的默认追杀预设和扩展方法,无需每个项目重复手配这组参数:
csharp
#if SERVER
using gamesparkcore.AISystem;
// 使用 GameSparkCore 内置的默认追杀AI
var waveAI = newEnemies.HuntAndAttack();
// 或指定固定目标
var waveAI2 = newEnemies.HuntAndAttack([hero]);
// 仅追击,不主动进入战斗
var waveAI3 = newEnemies.HuntWithoutAttack();
#endif上面的扩展方法底层使用 gamesparkcore.ScopeData.GameDataWaveAI.默认追杀AI。这套预设面向近战生存/刷怪场景,Boss、远程怪和大地图玩法仍应按实际需求复制后调整。
详细文档见 ../../../systems/AISystem.md。
玩家控制(MainUnit + GameSystemUI)
玩家控制分两步:服务端设置主控单位 + 客户端添加操控 UI。
MainUnit 绑定和默认镜头跟随不要求单位启用 Walkable 或 AllowMover;静态单位也可以绑定。只有实际使用寻路、移动摇杆或移动器时才配置对应能力,不要为了绑定镜头而开启移动能力。
服务端(设置主控单位,使客户端相机自动跟随):
csharp
#if SERVER
player.MainUnit = hero; // 必须设置,否则相机不跟随、无法操控
#endif客户端(添加移动摇杆和技能按钮):
csharp
#if CLIENT
// 等待 MainUnit 同步到客户端
var mainUnit = Player.LocalPlayer?.MainUnit;
// 平台自适应移动控件(手机=摇杆,PC=WASD/方向键)
var moveControl = GSUI.GameDataControl.MoveControl.Data!.CreateControl();
moveControl.AddToVisualTree();
// 技能摇杆组(自动显示主控单位的技能按钮)
var abilityGroup = new AbilityJoyStickGroup(GSUI.GameDataControl.DefaultAbilityJoyStickGroup)
{
BindUnit = mainUnit,
};
abilityGroup.AddToRoot();
#endif如果游戏没有 Unit / MainUnit,例如纯 GameGraph / SceneGraph 物理小游戏,可以只复用 GameSystemUI 的输入控件而不让它发单位移动指令:
csharp
#if CLIENT
using GameSystemUI.MoveSystem;
using System.Numerics;
var moveControlRoot = GSUI.GameDataControl.MoveControl.Data!.CreateControl();
moveControlRoot.AddToVisualTree();
var moveControl = moveControlRoot.GetMoveControl();
if (moveControl != null)
{
moveControl.UseMainUnit = false;
moveControl.BindUnit = null;
moveControl.OnMoveInput += (_, e) =>
{
var direction = e.State == MoveInputState.Stopped
? Vector2.Zero
: e.WorldDirection; // 已应用当前相机角度偏移
// 将 direction 应用到 SceneGraph 节点、相机、载具或自定义对象
};
}
#endif详细说明见 ../../../systems/GameSystemUIMoveControl.md。
> GameSystemUI 已内置:game_entry 模板的 csproj 已引用 GameSystemUI.dll,GameSystemUI.AbilitySystemUI.Advanced 已通过全局 using 导入。无需额外配置。
如需查看 GameSystemUI 控件实现或 GameSparkCore 默认预设/源数据,先用 docs/api/ 与本 skill;仍不足时可读编辑器下载缓存中的明文依赖库。GameSystemUI 负责通用 UI/输入系统(移动、技能、商店、背包等);GameSparkCore 负责通用玩法默认数据和辅助逻辑(属性、生命值、AI 预设、血条等);map_templates 只负责模板和示例,不是运行时依赖库。缓存路径:<EditorUpdateRoot>/res/_m/maps/gamesystemui/<version>/gamesystemui/ 和 <EditorUpdateRoot>/res/_m/maps/gamesparkcore/<version>/gamesparkcore/。示例路径:E:/WasmSCE/update/editor-master.spark.xd.com/res/_m/maps/gamesystemui/114/gamesystemui、E:/WasmSCE/update/editor-master.spark.xd.com/res/_m/maps/gamesparkcore/163/gamesparkcore。多个版本目录优先看最大版本号;这些缓存只读参考,不要写进项目代码或直接修改。
单位数据属性(UnitPropertyInitialData)
UnitPropertyInitialData 继承 QuickDictionary<IGameLink<GameDataUnitProperty>, double>,用字典初始化器:
| 属性 GameLink | 含义 |
|---|---|
GameDataUnitProperty.LifeMax | 最大生命值 |
GameDataUnitProperty.MoveSpeed | 移动速度 |
GameDataUnitProperty.TurningSpeed | 转向速度(Turnable = true 时必须设置,否则攻击时报错) |
GameDataUnitProperty.AttackDamage | 攻击力 |
GameDataUnitProperty.Armor | 护甲 |
GameDataUnitProperty.MagicResistance | 魔抗 |
GameDataUnitProperty.Sight | 视野范围 |
GameDataUnitProperty.AttackRange | 攻击距离 |
完整列表见 api/server/gamesparkcore_ScopeData.cs 中的 GameDataUnitProperty 类。
生命值系统(Vital)
生命值由 VitalProperties = [GameDataVital.Health] 自动启用。
csharp
// 获取生命值组件
var health = unit.GetTagComponent<Vital>(PropertyVital.Health);
if (health != null)
{
double currentHp = health.Current; // 当前 HP
double maxHp = health.Max; // 最大 HP
health.Modify(-50); // 扣 50 HP
health.Set(health.Max); // 回满 HP
}单位外观
有两种方式为单位设置 3D 外观:
方案 A:基本形状(快速原型) — 使用 PrimitiveShapeConfig,无需任何模型资源:
| 形状 | 适用场景 |
|---|---|
Capsule | 角色/玩家 |
Sphere | 敌人/弹丸 |
Cube | 建筑/箱子 |
Cylinder | 柱子/塔 |
Cone | 箭头/标记 |
颜色由 ShapeColorMode.SmartDefaults 自动分配,每种形状有不同颜色。也可通过 CustomColor 自定义。
方案 B:内置占位模型 — 使用 GameDataModel 指定真实 3D 模型,以下是所有项目内置的模型:
| 种类 | Asset 路径 | 说明 | Radius |
|---|---|---|---|
| 人形英雄 | characters1/baiyijianke_e4wa/model.prefab | 白衣剑客,推荐主角 | 52 |
| 人形通用 | characters/general/sk_basic2/model.prefab | 通用角色 | 50 |
| 小型怪物 | characters/monster/sm_slm_a/model.prefab | 史莱姆 A(小) | 40 |
| 中型怪物 | characters/monster/sm_slm_b/model.prefab | 史莱姆 B(中) | 60 |
| 大型怪物 | characters/monster/sm_slm_c/model.prefab | 史莱姆 C(大) | 80 |
| 蜘蛛怪 | characters/monster/sk_spider_burrow/model.prefab | 蜘蛛 | 32 |
| 狼人 | characters/monster/sk_werewolf/model.prefab | 狼人 | 50 |
| 石塔 | deco/dungeon/sk_garden_stonetower_a03/model.prefab | 防御塔/建筑 | — |
所有角色/怪物模型的标准动画别名:idle、move、attack、death。使用方式见 reference.md。
> 模型阴影要 opt-in,基本形状默认就带。 GameDataModel.ShadowSetting 默认为 null——真实模型不显式配置就不投阴影;而 PrimitiveShape(方案 A)框架已默认给了 DeviceDependentShadow。所以主角用真实模型、子弹/敌人用基本形状时,会出现"主角没影子、其它有影子"的不一致。要影子就给模型补 ShadowSetting = new() { ShadowType = ShadowType.DeviceDependentShadow };反过来不想要飞行物投影,就给投射物形状关掉。
> 更多资源:上表仅列出推荐模型。旧版角色/怪物完整列表见 resources/characters.json(1280+ 个模型含路径和模型自带动画列表);官方捏人角色见 resources/characters1.json(characters1/{name}/model.prefab,含 Asset 路径和模型自带动画名)。 > > 动画资源的两种来源: > - 模型自带动画:在 characters.json 或 characters1.json 每个模型的 anims 字段中列出。可以直接用原名播放(如 "attack_01"u8),也可以通过 AnimationMappings 映射别名后用别名播放。 > - 共享 Humanoid 动画:见 resources/animations.json(136 个动画集,含 hitPoint/cancelPoint 打点时间,可用于设置 Preswing/Backswing)。可以用完整路径(带 .ani)播放,也可以通过 AnimationMappings 映射后用别名播放。资源统计会尝试从单位 Model / Abilities、技能 Animation 和对应 GameDataAnimation 素材中自动推导烘焙列表;但这是依赖代码结构的静态分析,不一定覆盖 helper、工厂和运行时动态组装。关键动画仍建议显式加入 GameDataModel.HumanoidSourceAnimations 作为保险(未烘焙时虽能 fallback 播放原始动画,但可能出现体形不匹配)。 > - 共享 Humanoid 动画:见 resources/animations.json(136 个动画集,含 hitPoint/cancelPoint 打点时间,可用于设置 Preswing/Backswing)。可以用完整路径(带 .ani)播放,也可以通过 AnimationMappings 映射后用别名播放。资源统计会尝试从单位 Model / Abilities、技能 Animation 和对应 GameDataAnimation 素材中自动推导烘焙列表;但这是依赖代码结构的静态分析,不一定覆盖 helper、工厂和运行时动态组装。关键动画仍建议显式加入 GameDataModel.HumanoidSourceAnimations 作为保险(未烘焙时虽能 fallback 播放原始动画,但可能出现体形不匹配)。Maker 导入的外部 MDL 是例外:先按 Maker 人形工作流检查骨架并显式重定向,不能把该字段当作首次骨骼适配入口。 > - 运行时挂载资源:第一人称手臂、枪械视图、瞄具或其它运行时挂载的模型/动画,如果要随包发布,也必须进入可分析的数据链或显式资源清单;不要只把路径留在字符串常量里。 > > AnimationMappings 的主要作用:给动画起别名,同时指定框架自动查找的 idle、move、death 等硬编码别名对应哪个实际动画。
使用 3D 模型时,idle、move、death 由框架通过 AnimationMappings 别名自动播放,但 攻击和技能动画需要手动配置——用 GameDataAnimationSimple(File = "attack_01"u8,短普攻通常再设置 BlendIn = TimeSpan.Zero)定义动画,然后在 GameDataAbilityExecute 上设置 Animation = [link]。见上方完整示例。详细动画系统文档见 docs/systems/ModelAnimationSystem.md。
如果要做“站立时播放、单位开始移动后自动被移动动画打断”的手动动画,参考 ModelAnimationSystem.md 的“与默认 idle / move 状态机配合”:通常将该动画放在 AnimationLogicLayer.Rollback 且设置 Priority = 2。启用自定义 UnitLocomotionAnimation profile 后,以 profile 中各姿态动画的 LogicLayer / Priority 为准。
方案 C:仅 ActorArray(纯粒子特效单位) — 不设 Model,仅通过 ActorArray 挂载粒子特效。框架会为单位创建隐含的场景 Node,ActorArray 中的 Actor 自动附着在该 Node 上。适用于经验宝石、掉落物、纯视觉标记等不需要 3D 模型的场景实体:
csharp
// 1. 定义粒子特效资源
_ = new GameDataParticle(gemParticleLink) { Asset = "effect/eff_arpg/arpg_tongyong/diaoluo_1/particle.effect"u8 };
// 2. 定义粒子 Actor(持续播放)
_ = new GameDataActorParticle(gemActorLink) { AutoPlay = true, Particle = gemParticleLink };
// 3. 定义单位:无 Model,仅 ActorArray
_ = new GameDataUnit(gemUnitLink)
{
Name = "经验宝石",
Filter = [UnitFilter.Item],
ActorArray = [gemActorLink],
DeathProcedure = new() { Mode = DeathProcedureMode.Disintegrate },
DeathRemovalDelay = TimeSpan.Zero,
};移动方式
| 方式 | API | 用途 |
|---|---|---|
| 寻路移动 | Walkable.CreateInstance(unit).PathTo(target) | 绕过障碍物的路径移动 |
| 直接传送 | unit.SetPosition(scenePoint) | 瞬间移动 |
| 匀速移动 | unit.MoveTo(scenePoint) | 直线匀速移动(不寻路) |
| 指令移动 | unit.ProcessCommand(CommandIndex.Move, target) | 发出移动命令 |
| 3D 物理角色模式 | unit.ActivateUnitPhysicalCharacterMovement() / unit.ReturnUnitToSceneNavigation() | 在 Volumetric3D 场景中切换物理角色和 3D 寻路 |
| 跳跃 / 空中跳 | unit.TryJumpUnit() / unit.SetUnitExtraAirJumpCount(count) | 仅物理角色模式下由服务端请求 |
寻路移动需要 UpdateFlags.Walkable = true。
跨端通信(DualEndEvent)
csharp
// 定义事件(共享)
public class MyEvent : IDualEndEvent<MyEvent>
{
public Player? Sender { get; set; }
public int Score { get; set; }
}
// 服务端发送 → 客户端接收
#if SERVER
DualEndEvent.Publish(new MyEvent { Score = 100 });
#endif
#if CLIENT
Game.Subscribe<MyEvent>(async (s, d) => { ShowScore(d.Score); });
#endif- 服务端
Publish→ 广播到所有客户端 - 客户端
Publish→ 发送到服务端(Sender自动设置为发送者玩家) - 事件 payload 必须是公开
get; set;属性;框架默认 JSON 配置不序列化字段,不要写成public int Score;
Trigger 注册目标:
- 全局游戏事件(
EventGameStart、DualEndEvent)→Game.Subscribe<T>(handler)或.RegisterGlobal() - 单位事件(
EventUnitDeath等)→unit.Subscribe<T>(handler)注册到具体单位 - 类型级事件 →
gameDataUnit.Subscribe<T>(handler)注册到数据定义(监听同类型所有单位) - 玩家事件 →
.Register(player)注册到具体玩家
HUD 显示方式
优先使用 UI.* 流式布局 API 构建 HUD,比属性设置模式更简洁:
csharp
#if CLIENT
waveLabel = UI.Label("波次: 0").FontSize(20).TextColor(Color.White);
enemyLabel = UI.Label("剩余敌人: 0").FontSize(20).TextColor(Color.White);
UI.VStack(5, waveLabel, enemyLabel)
.Size(280, 60).AlignLeft().AlignTop().Margin(10)
.Background(Color.FromArgb(160, 0, 0, 0)).Padding(10)
.AddToRoot();
// 事件回调中直接更新文字
waveLabel.Text = $"波次: {waveNumber}";
#endif> GameUI.Control.Extensions 已通过 csproj 全局 using 导入,UI 类可直接使用(UI.Label()、UI.VStack() 等)。 > Canvas 适用于需要自定义绘制的场景(小地图、血条图形、粒子等),简单文本信息不需要用 Canvas。
常见错误速查
| 症状 | 原因 | 解决 |
|---|---|---|
| 3000+ 编译错误 | 未用 -c Server-Debug / -c Client-Debug | 正确配置 |
ComponentTagEx.Walkable 找不到 | 错误类名 | 用 ComponentTag.Walkable |
| 敌人站着不动 | TacticalAI 未指向可用的战斗 AIThinkTree,或缺少 AI 所需的攻击能力,或单位创建后始终没挂上 AI | 数据定义中配置 TacticalAI(默认或自定义)和所需 Abilities;创建时传 useDefaultAI: true,或后续手动 AddDefaultAI/CreateAI |
| 怪物会巡逻/追人但不攻击 | CreateUnit(..., useDefaultAI: false) 后也没手动挂战斗 AI,或 StartWaveAI(..., false) / SetCombatEnabled(false) / SetRoute(..., false) 把 WaveAI 战斗关掉了 | 确认单位最终真的挂了 AIThinkTree;WaveAI 默认用 true 或 null,不要误传 false |
| 敌人能扫到玩家、但不接近也不攻击(明明在攻击距离外) | 自定义 CombatBehaviorTree 子树里的 GameDataAINodeValidateScan.Range 被压成 AttackRange,导致进入战斗后 ScanTargetThisTick 为 null → ValidateCast InvalidTarget → 不接近。战斗外 Scan 用 AIThinkTree.ScanRange(默认 ≥ 500),战斗内 Scan 用自定义子树的 Range,两者独立。 | 战斗内 Scan 节点的 Range 保持 null,回落到 AIThinkTree.ScanRange;或显式给一个 ≥ 期望追击距离的值(例如 1500)。不要让战斗内 Scan 比战斗外更小。 |
| 使用默认 AI 时仍不攻击 | 单位 Filter 未包含 UnitFilter.Unit,默认 AI 的 ScanFilters 要求目标具有此标签 | 英雄和敌人的 GameDataUnit 添加 Filter = [UnitFilter.Unit](英雄可加 UnitFilter.Hero) |
| 英雄无法攻击 | 未给英雄配置攻击技能 | 定义 GameDataAbilityExecute(IsAttack = true)并设 Abilities = [attackLink] |
| 攻击打自己 | AcquireSettings.TargetingFilters 为空,放行所有目标含自身 | 添加 AcquireSettings + Required = [UnitRelationship.Enemy] |
| 单位没有血条 | 未配置 StatusBarSetting | 添加 StatusBarSetting,按阵营分配 StatusBar 变体 |
| 血条左侧有黑色块/等级黑块 | 使用 Hero 状态栏变体但单位没有有效等级;左侧区域是等级槽 | 不显示等级则改用 Normal 变体;显示等级则添加 Leveling = GSC.GameDataUnitLeveling.HeroLevelingSample + Level = 1 |
| 主角(模型)没影子,但子弹/敌人(基本形状)有影子 | GameDataModel.ShadowSetting 默认 null=不投影;PrimitiveShape 默认带 DeviceDependentShadow | 给模型显式设 ShadowSetting = new() { ShadowType = ShadowType.DeviceDependentShadow }(不想要投影的形状则反向关掉) |
| 同一骨骼模型在主镜头和小地图 / 画中画中同时渲染时蒙皮撕裂、肢体拉伸或姿态错乱 | 纹理蒙皮的骨骼 atlas 与 Compute 蒙皮生成的 geometry 仍可能共享 Drawable / 场景级状态;不同 View 的更新顺序或可见集合会互相覆盖 | 短期将该模型设为 SimpleSkinning = true、ComputeShaderSkinning = false,或让小地图过滤角色;SimpleSkinning 是模型级开关,不是 View 隔离,且受设备最大骨骼数限制(客户端能力版本 3)。长期等待引擎完成 View-local 蒙皮状态隔离 |
| 攻击时报错 CannotTurnWhenSpeedIsZero | Turnable = true 但未设 TurningSpeed | Properties 中添加 { GSC.GameDataUnitProperty.TurningSpeed, 720 } |
| 技能按钮显示原始 ID | 攻击技能未设 DisplayName | 设置 DisplayName = "攻击" |
| 玩家无法操控英雄 | 未设置 MainUnit 或未添加操控 UI | 服务端 player.MainUnit = hero + 客户端添加 MoveControl |
| 相机不跟随英雄 | 未设置 player.MainUnit | 服务端 player.MainUnit = hero |
| 单位不移动 | UpdateFlags.Walkable 未设为 true | 在 GameDataUnit 中启用 |
unit.PathTo() 找不到 | PathTo 不在 Unit 上 | 用 Walkable.CreateInstance(unit).PathTo(target) |
| 客户端收不到事件 | 未注册触发器 | 检查 Trigger 是否已 Register(Game.Instance) |
| 客户端收到事件但数据是默认值 | DualEndEvent 事件数据写成了字段,如 public int Score;,框架默认 JSON 配置不序列化字段 | 改为 public int Score { get; set; };Sender 也必须是属性 |
| 生命值不生效 | 未配置 VitalProperties | 添加 VitalProperties = [GameDataVital.Health] |
| 属性 GameLink 找不到 | 需要 GSC 别名 | 使用 GSC.GameDataUnitProperty.LifeMax(csproj 已配置全局别名) |
HeightGrow/WidthGrow 编译错误 | 在对象初始化器中当属性用 | 它们是扩展方法,用 .HeightGrow(1) 链式调用,或用原生属性 HeightStretchRatio = 1 |
Game.Time 用法不对 | 返回 TimeSpan 非毫秒 | 用 Game.Time.TotalSeconds 或 Game.TotalElapsedTickInMilliseconds |
Scene.CreateNewScene() 不存在 | 该 API 已移除 | 用 Scene.GetOrCreate(GameEntry.ScopeData.GameDataScene.new_scene) + Load() |
| 场景加载后无地形 | HostedSceneTag 不匹配场景资产 | 使用 GameEntry.ScopeData.GameDataScene 中已有的场景,不要编造标签 |
Player.IsValid 不存在 | Player 无此属性 | 用 player.IsNeutral 跳过中立玩家 |
| 敌人不会被攻击/不算敌人 | 敌人属于中立玩家(ID=0),GetRelationShip 返回 Neutral | 查看 PlayerSettings.cs 队伍配置,选择不同队伍的非中立玩家。默认模版中 Player.GetById(3)(队伍 2)与玩家 1/2(队伍 1)敌对 |
| 敌人不追杀玩家(只在原地等) | 仅有 TacticalAI,扫描范围有限(默认 500-1000) | 生存类游戏用 GameDataWaveAIHunt 追杀模式,不要用 AttackTarget 轮询 |
| Vector 技能不造伤害 | Vector 技能的主目标是坐标点,EffectDamage 需要单位目标 | 在技能和伤害之间插入 EffectSearch(扇形/圆形),搜索范围内单位后再造伤害 |
| 子弹/技能方向不对,手动转角色也没用 | 投射物方向取自技能 ExecutionTarget(索敌/向量方向),与单位 Facing 无关 | 用 Vector + AlwaysAcquireTarget + 无目标 CastAbilityWithMainUnit(技能) 让引擎索敌定向;删掉手动 SetFacing、客户端差分算朝向、!IsSuccess 补发(详见 reference.md 投射物系统) |
| 瞬发技能弹药/消耗不生效(挂 Order 阶段事件没触发) | 瞬发技能(Transient=true)不经过 Cast/Channel 阶段,EventEntityOrderStage handler 是死代码 | 消耗放效果树:首节点 Validators 当门槛(失败则指令下达失败),GameDataEffectCustomAction.Func 做消耗(详见 reference.md) |
AttackTarget 编译报错 | 扩展方法在 TriggerEncapsulation.Commands 命名空间 | 添加 using TriggerEncapsulation.Commands; |
开发检查清单
- [ ] 使用项目已有的
GameEntry.ScopeData.GameDataGameMode.MapGameMode(它与地形编辑器、玩家队伍设置联动,已包含完整的游戏模式配置。除非明确需要多个游戏模式,否则不要新建) - [ ] 场景使用项目中已有的
GameEntry.ScopeData.GameDataScene.new_scene(不要编造 HostedSceneTag) - [ ] 不要把
SceneSpaceModel.Surface2D误判成无高度的 2D 场景;它是 2.5D 单层地表寻路,SCE 场景仍可有 Z 高度 - [ ]
OnRegisterGameClass中订阅了Game.OnGameDataInitialization +=(数据类)和Game.OnGameTriggerInitialization +=(逻辑类) - [ ] 不需要修改
GlobalConfig.cs(MapGameMode 已注册为默认测试模式) - [ ]
GameDataUnit配置了Filter(战斗单位至少包含UnitFilter.Unit,否则 AI 无法检测到该单位;英雄加UnitFilter.Hero) - [ ]
GameDataUnit设置了UpdateFlags.Walkable = true(如需移动) - [ ]
GameDataUnit配置了VitalProperties = [GameDataVital.Health](如需生命值) - [ ]
GameDataUnit配置了Properties初始属性(LifeMax、MoveSpeed 等) - [ ]
GameDataUnit配置了PrimitiveShape、Model或ActorArray(至少一种,否则看不到;仅ActorArray适用于纯粒子特效单位如掉落物) - [ ] 单位配置了攻击技能(
GameDataAbilityExecute+GameDataEffectDamage+Abilities = [...]) - [ ] 攻击技能配置了
AcquireSettings.TargetingFilters(Required = [UnitRelationship.Enemy];空过滤器放行所有目标含自身) - [ ] 攻击技能设置了
DisplayName(否则技能按钮显示原始 GameLink ID) - [ ]
Turnable = true的单位在 Properties 中设置了TurningSpeed(否则攻击时报错) - [ ] 英雄配置了
StatusBarSetting(否则没有血条) - [ ] 使用 Hero 状态栏变体的单位配置了
Leveling(否则等级槽位显示黑块) - [ ] 敌人/NPC 配置了可用的
TacticalAI,并满足该 AI 所需的攻击能力;单位创建后最终真的挂上了AIThinkTree(useDefaultAI: true或手动AddDefaultAI/CreateAI) - [ ] 使用 WaveAI 的战斗单位没有在
StartWaveAI/SetCombatEnabled/SetRoute中把战斗覆盖成false - [ ] 生存游戏的敌人使用
GameDataWaveAIHunt追杀模式(仅 TacticalAI 只在扫描范围内有效,不会全图追击) - [ ] 非 Unit 目标类型的技能(Vector/Ground/None)使用
EffectSearch中转再接EffectDamage - [ ] 服务端设置了
player.MainUnit = hero(否则相机不跟随、无法操控) - [ ] 客户端添加了
MoveControl(移动摇杆/键盘)和AbilityJoyStickGroup(技能按钮) - [ ] 敌方单位属于非中立的敌对阵营玩家(查看
PlayerSettings.cs确认队伍配置,不要使用中立玩家 ID=0) - [ ] 服务端单位创建代码包裹在
#if SERVER中 - [ ] 客户端 UI 代码包裹在
#if CLIENT中 - [ ] DualEndEvent 的
Trigger已注册到全局(使用Game.Subscribe<T>()或.RegisterGlobal()) - [ ] DualEndEvent 事件 payload 使用
public get; set;属性,不使用字段 - [ ] 使用
Game.Logger而非Console.WriteLine - [ ] 使用
Game.Delay()而非Task.Delay()
骨骼蒙皮路径选择
客户端 AnimatedModelComponent 支持三种蒙皮路径,按以下优先级生效:
SimpleSkinning = true:普通硬件骨骼蒙皮。骨骼矩阵直接进入顶点着色器,受设备最大骨骼数限制;需要客户端能力版本3,适合作为小地图或画中画角色的临时规避方案。SimpleSkinning = false且ComputeShaderSkinning = true:Compute Shader 蒙皮。需要 GPU Compute Shader 支持,适合骨骼较多的模型。- 两个开关都为
false:CE 默认纹理蒙皮。骨骼矩阵写入共享纹理 atlas,可突破传统骨骼数限制,但同一角色被多个 View 同时渲染时,View-local 蒙皮状态隔离仍是后续长期工作。
示例:
csharp
var animated = node.GetComponent<AnimatedModelComponent>();
animated.SimpleSkinning = true; // 普通硬件蒙皮
animated.SimpleSkinning = false;
animated.ComputeShaderSkinning = true; // Compute Shader 蒙皮
animated.SimpleSkinning = false;
animated.ComputeShaderSkinning = false; // CE 默认纹理蒙皮更多详细信息
完整文档(含属性系统详解、Vital 系统、伤害系统、移动系统深入说明)见 reference.md。