vault backup: 2026-05-31 15:35:41

This commit is contained in:
2026-05-31 15:35:41 +08:00
parent a507664f3e
commit 273d136c75
5 changed files with 1996 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
---
tags:
- UE
- EasyEditorPlugin
- Puerts
- Editor
- TypeScript
created: 2026-05-31
---
# EasyEditorPlugin 架构分析
## 概述
EasyEditorPlugin 是 `johnche`(腾讯,也是 Puerts 的作者)开发的 Unreal Engine **编辑器快速扩展插件**。它将 V8 JavaScript 运行时(通过 Puerts 的 `FJsEnv`)嵌入 Unreal Editor让开发者可以用 TypeScript/JavaScript 编写编辑器扩展,而非 C++。
- **仓库**: `D:\MatrixTA\EasyEditorPlugin`
- **许可证**: MIT
- **引擎兼容**: UE 5.3+
- **核心价值**: 几行 TypeScript 完成编辑器菜单、工具栏、控制台命令、Detail 面板定制,支持热重载
## 目录结构
```
EasyEditorPlugin/
├── .gitignore
├── EasyEditorPlugin.uplugin # UE 插件描述符
├── LICENSE # MIT
├── README.md # 使用文档(英文)
├── Resources/
│ └── Icon128.png # 插件图标
├── Doc/
│ ├── extension.md # 第三方 C++ 库扩展指南
│ └── Pic/
│ └── easyeditor.gif # 演示动画
└── Source/
└── EasyEditorPlugin/
├── EasyEditorPlugin.Build.cs # UE 构建规则
├── Public/
│ └── EasyEditorPlugin.h # 模块头文件(公开 API
└── Private/
└── EasyEditorPlugin.cpp # 完整实现(~320 行)
```
**单模块 Editor 插件**,无 Runtime/Gameplay 模块。
## .uplugin 定义
```json
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "EasyEditorPlugin",
"CanContainContent": true,
"Modules": [{
"Name": "EasyEditorPlugin",
"Type": "Editor", // ← 仅在 Unreal Editor 中加载
"LoadingPhase": "Default"
}],
"Plugins": [{
"Name": "Puerts",
"Enabled": true // ← 硬依赖 Puerts
}]
}
```
## Build.cs 模块依赖
| 依赖 | 类型 | 用途 |
|------|------|------|
| `Core` | Public | 基础 UE 类型(`FString`, `TSharedPtr` 等) |
| `CoreUObject` | Private | UObject 系统 |
| `Engine` | Private | Engine 层类型 |
| `Slate` | Private | Slate UI 框架 |
| `SlateCore` | Private | 核心 Slate 类型(`FSlateIcon` |
| `ToolMenus` | Private | UE5 可扩展菜单系统 |
| `UnrealEd` | Private | 编辑器 API地图变更通知等 |
| `ContentBrowserData` | Private | Content Browser 上下文菜单 |
| **`JsEnv`** | Private | **Puerts JavaScript 环境** |
## 公开 API`EasyEditorPlugin.h`
```cpp
class FEasyEditorPluginModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
std::function<void()> OnJsEnvPreReload; // JS 环境重启前回调
std::function<void(const FString&)> Eval; // 执行 JS 字符串
TSparseArray<TUniquePtr<FAutoConsoleCommand>> TsConsoleCommands; // TS 注册的控制台命令
FSimpleMulticastDelegate OnJsEnvCleanup; // JS 环境清理广播
private:
TSharedPtr<puerts::FJsEnv> JsEnv; // ← 私有!外部无法访问
TSharedPtr<puerts::FSourceFileWatcher> SourceFileWatcher;
TUniquePtr<FAutoConsoleCommand> ConsoleCommand;
bool StartupScriptCalled = false;
void OnPostEngineInit();
void InitJsEnv();
void UnInitJsEnv();
bool Tick(float);
void HandleMapChanged(UWorld* InWorld, EMapChangeType InMapChangeType);
};
```
**关键点:`JsEnv` 是私有成员,外部 C++ 代码无法直接获取 JS 函数/对象。**
## Puerts 绑定(`AutoRegisterForEEP`
静态初始化器在模块加载前(`StartupModule` 之前)自动注册以下 C++ 类型到 V8
| C++ 类型 | 暴露的功能 |
|-----------|----------|
| `FSlateIcon` | 三个构造函数重载 |
| `FToolMenuEntry` | `InitMenuEntry`, `InitToolBarButton`, `InitComboButton` |
| `EasyEditorPlugin` | `SetOnJsEnvPreReload`, `SetEval`, `AddConsoleCommand`, `RemoveConsoleCommand` |
| `FContentBrowserItem` | `IsFolder`, `IsFile`, `GetItemName`, `GetItemPhysicalPath` |
## 生命周期
```
Module Load → AutoRegisterForEEP 构造(注册 Puerts 绑定)
StartupModule()
├── 设置 V8 --expose-gc 标志(允许显式 GC
├── 注册 OnPostEngineInit 回调
└── 注册 OnMapChanged 处理器(地图卸载时 GC
OnPostEngineInit()
├── InitJsEnv()
│ ├── 创建 FSourceFileWatcher热重载
│ └── 创建 FJsEnvModuleLoader 指向 "EasyEditorScripts"
├── 注册每帧 Tick
├── 注册 "EasyEditor.Restart" 控制台命令
└── 注册 "TypeScript" UToolMenus 字符串命令处理器
First Tick()
└── JsEnv->Start("Main") // 执行 Main.ts
[热重载循环]
SourceFileWatcher 检测文件变更
→ 读取文件
→ JsEnv->ReloadSource(InPath, Content)
ShutdownModule()
└── UnInitJsEnv()
├── Broadcast OnJsEnvCleanup
├── 清除 Eval / OnJsEnvPreReload
├── 清空 TsConsoleCommands
└── 重置 JsEnv 和 SourceFileWatcher
```
## `InitJsEnv()` 详细分析
```cpp
void FEasyEditorPluginModule::InitJsEnv()
{
// 1. 创建文件监听器(用于热重载)
SourceFileWatcher = MakeShared<puerts::FSourceFileWatcher>(
[this](const FString& InPath) {
if (JsEnv.IsValid()) {
TArray<uint8> Source;
if (FFileHelper::LoadFileToArray(Source, *InPath)) {
JsEnv->ReloadSource(InPath, puerts::PString(
(const char*)Source.GetData(), Source.Num()));
}
}
});
// 2. 创建 JsEnv
// 关键: DefaultJSModuleLoader("EasyEditorScripts")
// → 从项目 Content/EasyEditorScripts/ 加载 TS 文件
JsEnv = MakeShared<puerts::FJsEnv>(
std::make_shared<puerts::DefaultJSModuleLoader>(TEXT("EasyEditorScripts")),
std::make_shared<puerts::FDefaultLogger>(), -1,
[this](const FString& InPath) {
if (SourceFileWatcher.IsValid()) {
SourceFileWatcher->OnSourceLoaded(InPath);
}
});
}
```
**当前限制**
- `DefaultJSModuleLoader("EasyEditorScripts")` 只在项目 `Content/` 目录下搜索
- 不搜索插件 Content 目录
- 不能直接从插件的 `Resources/``Content/` 加载 TS
## 编辑器集成能力
### 菜单扩展(`UToolMenus`
- `FToolMenuEntry.InitMenuEntry` — 添加普通菜单项
- `FToolMenuEntry.InitToolBarButton` — 添加工具栏按钮
- `FToolMenuEntry.InitComboButton` — 添加下拉工具栏按钮
### Content Browser 上下文菜单
- `FContentBrowserItem.IsFolder()`, `IsFile()`, `GetItemName()`, `GetItemPhysicalPath()`
### 控制台命令
- `EasyEditorPlugin.AddConsoleCommand(name, help, callback)` — 注册控制台命令
- `EasyEditorPlugin.RemoveConsoleCommand(index)` — 注销
### String Command Handler
`UToolMenus::RegisterStringCommandHandler("TypeScript", ...)` 让菜单项可以触发任意 TypeScript 代码。
### 重启命令
内置 `EasyEditor.Restart` 控制台命令:销毁 JS 环境 → 重新创建 → 重新加载 Main 脚本。
## 扩展点(给其他插件使用)
| 扩展点 | 类型 | 用途 |
|--------|------|------|
| `OnJsEnvCleanup` | `FSimpleMulticastDelegate` | JS 环境销毁前,其他插件清理状态 |
| `OnJsEnvPreReload` | `std::function<void()>` | 重启前执行 |
| `Eval` | `std::function<void(const FString&)>` | 执行任意 JS 字符串 |
其他插件的集成方式(以 EasyEditor_ImGui 为例):
```cpp
// 注册清理回调
FModuleManager::LoadModuleChecked<FEasyEditorPluginModule>("EasyEditorPlugin")
.OnJsEnvCleanup.AddLambda([]() {
// 清理状态
});
```
## 脚本组织
- **脚本目录**: `Content/EasyEditorScripts/`(项目 Content 下)
- **入口文件**: `Main.js``Main.ts`
- **语言**: TypeScript主要/ JavaScript
- **热重载**: ✅ 支持
## 当前缺少的能力
1. **JsEnv 不公开** — 外部 C++ 无法获取 JS 函数引用(`FJsObject`
2. **ModuleLoader 不灵活** — 只支持项目 Content 目录,不支持插件 Content 目录
3. **Editor-Only** — 无法用于游戏运行时业务逻辑
4. **无返回值调用**`Eval` 只能执行字符串,无法获取返回值
5. **无结构化的 C++ → TS 调用 API** — 只能通过字符串 Eval 或 UE 反射间接调用
## 总结
| 方面 | 详情 |
|------|------|
| 设计目标 | 编辑器快速扩展 |
| 插件类型 | Editor-only |
| 运行时引擎 | V8通过 `puerts::FJsEnv` |
| 脚本语言 | TypeScript / JavaScript |
| 脚本入口 | `Main.ts`,从 `EasyEditorScripts/` 加载 |
| C++ 代码量 | ~320 行(极简) |
| 热重载 | ✅ |
| 可扩展性 | 其他插件可通过 `OnJsEnvCleanup` 回调集成 |
| 核心限制 | JsEnv 私有、脚本路径固定、Editor-Only |

View File

@@ -0,0 +1,363 @@
---
tags:
- UE
- Puerts
- TypeScript
- V8
- 架构分析
created: 2026-05-31
---
# Puerts 架构深度分析
## 概述
Puerts发音 "pu-erts")是腾讯开源的 Unreal Engine TypeScript/JavaScript 运行时。它将 V8 JavaScript 引擎直接嵌入 UE 的模块系统,实现 C++ 和 TypeScript 的双向互操作。
- **仓库**: `D:\MatrixTA\puerts`
- **UE 端源码**: `puerts\unreal\Puerts\`
- **许可证**: BSD 3-Clause
## 插件结构
`Puerts.uplugin` 定义了 **6 个模块**,有严格的初始化顺序:
| 模块 | 类型 | LoadingPhase | 用途 |
|------|------|-------------|------|
| **WasmCore** | Runtime | PreDefault | WebAssembly (wasm3) 支持 |
| **JsEnv** | Runtime | PreDefault | 核心 V8 嵌入引擎 |
| **ParamDefaultValueMetas** | Program | PostConfigInit | UHT 插件,参数默认值 |
| **Puerts** | Runtime | PostEngineInit | 高层模块设置、JsEnv 生命周期、PIE 钩子 |
| **DeclarationGenerator** | Editor | Default | 从 UE 反射生成 `*.d.ts` |
| **PuertsEditor** | Editor | PostEngineInit | 编辑器集成、TS 编译监听 |
### 依赖链
```
WasmCore → Core, CoreUObject, Engine
JsEnv → Core, CoreUObject, Engine, UMG, ParamDefaultValueMetas, WasmCore, Json
Puerts → Core, CoreUObject, Engine, Serialization, OpenSSL, UMG, JsEnv
PuertsEditor → Puerts, JsEnv, UnrealEd, LevelEditor, DirectoryWatcher, AssetRegistry
DeclarationGenerator → Puerts, JsEnv, UnrealEd
```
JsEnv 链接 V8 静态库(`wee8.lib` / `v8_11.8.172`),也可切换为 QuickJS 或 Node.js 后端。
## 启动流程
### 1. V8 Isolate 初始化
`FJsEnvImpl` 构造函数(`JsEnvImpl.cpp:347-646`
1. 创建 `v8::Isolate`,分配默认 ArrayBuffer 分配器
2. 创建 `v8::Context` 存入 `DefaultContext`
3.`this` 指针存为 Isolate 数据:`Isolate->SetData(0, static_cast<IObjectMapper*>(this))`
### 2. C++ → JS 桥接注册
构造函数注册 C++ 回调为全局 JavaScript 函数:
```cpp
MethodBindingHelper<&FJsEnvImpl::EvalScript>::Bind(..., "__tgjsEvalScript", ...);
MethodBindingHelper<&FJsEnvImpl::SearchModule>::Bind(..., "__tgjsSearchModule", ...);
MethodBindingHelper<&FJsEnvImpl::LoadModule>::Bind(..., "__tgjsLoadModule", ...);
MethodBindingHelper<&FJsEnvImpl::FindModule>::Bind(..., "__tgjsFindModule", ...);
MethodBindingHelper<&FJsEnvImpl::LoadUEType>::Bind(..., puertsObj, "loadUEType", ...);
MethodBindingHelper<&FJsEnvImpl::LoadCppType>::Bind(..., puertsObj, "loadCPPType", ...);
MethodBindingHelper<&FJsEnvImpl::NewObjectByClass>::Bind(..., "__tgjsNewObject", ...);
MethodBindingHelper<&FJsEnvImpl::MakeUClass>::Bind(..., "__tgjsMakeUClass", ...);
MethodBindingHelper<&FJsEnvImpl::Mixin>::Bind(..., "__tgjsMixin", ...);
// + setTimeout, setInterval, dumpStatisticsLog, FNameToArrayBuffer 等
```
### 3. Bootstrap JS 模块(按顺序执行)
```cpp
ExecuteModule("puerts/first_run.js"); // Inspector 存根(可选)
ExecuteModule("puerts/polyfill.js"); // JS polyfills非 NodeJS 模式)
ExecuteModule("puerts/log.js"); // console.log → UE_LOG
ExecuteModule("puerts/modular.js"); // CommonJS/ESM 模块系统
ExecuteModule("puerts/uelazyload.js"); // UE 类型懒加载 (Proxy)
ExecuteModule("puerts/events.js"); // EventEmitter
ExecuteModule("puerts/promises.js"); // Promise 支持
ExecuteModule("puerts/argv.js"); // 命令行参数
ExecuteModule("puerts/jit_stub.js"); // JIT 模块存根
ExecuteModule("puerts/hot_reload.js"); // V8 Inspector 热重载
ExecuteModule("puerts/pesaddon.js"); // PESAPI 原生 addon 加载
```
### 4. `Start()` 入口
`FJsEnvImpl::Start()` 调用 `require(ModuleName)` 执行用户入口模块。
## 模块解析系统
### 核心文件
- `puerts/modular.js` — JS 端 CommonJS/ESM 模块系统
- `JSModuleLoader.h` — C++ `IJSModuleLoader` 接口
- `DefaultJSModuleLoader.cpp` — 文件系统解析实现
### `genRequire()` 工厂
`modular.js` 实现 Node.js 兼容的 CommonJS 系统:
```javascript
// require() 解析流程
1. 尝试 org_require (Node.js 原生如果使用 Node.js 后端)
2. 检查 buildinModule 缓存 ("ue", "puerts", "cpp")
3. 调用 __tgjsFindModule(moduleName) 搜索 PUERTS_MODULE 注册的 C++ addon
4. 调用 __tgjsSearchModule(moduleName, requiringDir) 文件系统搜索见下
5. 调用 __tgjsLoadModule(fullPath) 加载文件
6. CommonJS wrapper 执行: (function(exports, require, module, __filename, __dirname) { ... })
7. ES 模块 (.mjs/.mbc): 使用 V8 原生 v8::Module API
```
### 文件系统搜索(`DefaultJSModuleLoader::Search`
搜索算法:
1.`RequiredDir/RequiredModule` 查找(尝试扩展名:`.js`, `.mjs`, `.cjs`, `.mbc`, `.cbc`, `package.json`, `index.js`
2.`RequiredDir/node_modules/RequiredModule` 查找
3. 向上遍历目录树,搜索每个父目录的 `node_modules`
4. 回退到 `Content/ScriptRoot/RequiredModule`(默认 `Content/JavaScript/`
5. 如果 ScriptRoot 不是 `"JavaScript"`,也回退到 `Content/JavaScript/`
**注意:搜索范围不包括插件 Content 目录。**
## JsEnv 数据流
### `puerts/uelazyload.js` — UE 类型懒加载
这是最关键的模块之一:
1. 创建全局 `UE` 对象作为 **Proxy**,访问属性时懒加载 UClass 类型
2. 创建全局 `CPP` 对象用于注册的 C++ 类型
3.`UE` 注册为内置模块:`require('ue')` 返回它
4. 暴露:`NewObject`, `NewStruct`, `FNameLiteral`, `NewArray/Set/Map`, `makeUClass`, `mixin`, `blueprint.tojs/load/unload`
5. 定义 UE 反射常量:`FunctionFlags`, `ClassFlags`, `PropertyFlags`
6. Decorator 存根:`uclass`, `ufunction`, `uproperty`, `uparam`
### `puerts/callable.js`(旧版/替代模式)
使用 `sendRequestSync`(同步 C++ 桥接)通过 JSON 序列化和 Proxy 包装调用 UE 方法。这是旧的 callable 模式。
### `puerts/hot_reload.js`
使用 V8 Inspector 协议 (`Debugger.setScriptSource`) 在运行时热重载 JS 模块,无需重启游戏。触发 `'HMR.prepare'``'HMR.finish'` 事件。
### `puerts/pesaddon.js`
包装 `puerts.load()` 加载原生 `.dll`/`.dylib`/`.so` addon使用 PESAPIPortable Embedded Scripting API
## Puerts 模块(`FPuertsModule`
### 关键文件
- `PuertsModule.h``IPuertsModule` 接口
- `PuertsModule.cpp` — 实现
- `PuertsSetting.h` — 设置 UObject
### `StartupModule()`
```cpp
void FPuertsModule::StartupModule() {
RegisterSettings(); // 从 DefaultPuerts.ini 加载设置
// Editor: 绑定 PIE PreBeginPIE/EndPIE 委托
// 绑定热重载委托
if (Settings.AutoModeEnable) {
Enable(); // 创建 JsEnv注册对象监听器
}
}
```
### `MakeSharedJsEnv()` — JsEnv 工厂
根据 `UPuertsSetting` 创建:
- **单一 JsEnv** (`FJsEnv`) — 普通模式
- **JsEnvGroup** (`FJsEnvGroup`) — 多隔离模式2-9 个实例)
关键设置:
- `RootPath` — JS 源码根目录(默认 `"JavaScript"`,相对于 `Content/`
- `AutoModeEnable` — 模块加载时自动启动
- `DebugEnable` / `DebugPort` — V8 Inspector 调试
- `NumberOfJsEnv` — 多隔离模式数量
### UObject 创建监听
`FPuertsModule` 同时实现 `FUObjectCreateListener`,每个新创建的 `UObject` 触发 `NotifyUObjectCreated()``JsEnv->TryBindJs(InObject)`。这就是 Puerts 自动将 TypeScript 行为注入 Blueprint 生成对象的机制。
## TS → C++ 绑定架构
### `UTypeScriptGeneratedClass` 和 `UTypeScriptBlueprint`
- `UTypeScriptBlueprint` — TypeScript 类的 Blueprint 资产类型
- `UTypeScriptGeneratedClass` — TS 生成的运行时 UClass
- `DynamicInvoker``TWeakPtr<ITsDynamicInvoker>`,回调 `FJsEnvImpl`
- `FunctionToRedirect` — 需要从 C++ 重定向到 JS 的函数名集合
- `HasConstructor` — TS 类是否有 `Constructor` 方法
- `StaticConstructor` — 替换原生 C++ 构造函数
### `UJSGeneratedFunction` 和 Magic Bytecode
`UJSGeneratedFunction` 是动态创建的 `UFunction`,通过 **magic bytecode trick** 重定向到 JS
1. 在 UFunction 的 `Script` bytecode 中存储自身指针3 字节 magic marker + 8 字节指针)
2. `execCallJS` 执行时恢复指针
3. 调用 `DynamicInvoker->InvokeJsMethod()``FJsEnvImpl::InvokeJsMethod()`
4. `FunctionTranslator->CallJs()` 将所有参数编组到 V8调用 JS 函数
### `MakeUClass` / `Mixin` — 运行时类创建
- **`MakeUClass`** (`puerts.makeUClass(ctor)`):接收 JS 构造函数、原型、类名、方法对象、父 UClass创建 `UJSGeneratedClass`,遍历父类 `FUNC_BlueprintEvent`,为每个覆盖创建 `UJSGeneratedFunction`
- **`Mixin`** (`puerts.blueprint.mixin(to, mixinMethods)`):接收现有 UClass添加方法覆盖
## C++ 调用 TS 的方式
### 方式 1`FJsObject`(直接 JS 函数包装器)
```cpp
// FJsObject 是 USTRUCT持有持久化 V8 引用
FJsObject MyFunc;
MyFunc.Action<int32, FString>(42, TEXT("hello")); // 无返回值
int32 Result = MyFunc.Func<int32, int32, FString>(42, TEXT("hello")); // 有返回值
int32 Val = MyObj.Get<int32>("someProperty"); // 读取属性
MyObj.Set("someProperty", 42); // 设置属性
```
自动使用 `puerts::v8_impl::Converter<T>` 进行 C++ ↔ V8 类型转换。线程安全(`THREAD_SAFE` 模式下使用 `v8::Locker`)。
### 方式 2UFUNCTION / BlueprintEvent 覆盖
标准 UE 模式:
```cpp
// C++ 中定义
UFUNCTION(BlueprintImplementableEvent)
void OnSomethingHappened(int32 Param);
// TypeScript 中实现
class MyActor extends UE.Actor {
OnSomethingHappened(Param: number): void {
// 被 C++ 调用时执行
}
}
```
### 方式 3委托
```typescript
import { toDelegate, toManualReleaseDelegate } from "puerts";
let d = toDelegate(myObject, myCallback); // 绑定到对象
let d2 = toManualReleaseDelegate(myCallback); // 独立委托
```
C++ 侧,`FJsEnvImpl` 维护 `DelegateMap` 将委托指针映射到 JS 回调函数。
### 方式 4`ITsDynamicInvoker` 接口
- `IDynamicInvoker::InvokeJsMethod` — 从 `UJSGeneratedFunction::execCallJS` 调用
- `IDynamicInvoker::JsConstruct` — 运行 JS 构造函数
- `ITsDynamicInvoker::TsConstruct` — TS 特定构造路径
`FJsEnvImpl` 通过内部类 `DynamicInvokerImpl``TsDynamicInvokerImpl` 实现这些接口。
### 方式 5`PUERTS_MODULE` 宏Addon 模块系统)
C++ 代码注册为 addon 模块:
```cpp
// 在任何 C++ 文件中
PUERTS_MODULE(MyPlugin, [](v8::Local<v8::Context> Context, v8::Local<v8::Object> Exports) {
// 注册类、函数等
Exports->Set(...);
});
```
使用静态自动注册:
```cpp
#define PUERTS_MODULE(Name, RegFunc) \
static struct FAutoRegisterFor##Name { \
FAutoRegisterFor##Name() { \
PUERTS_NAMESPACE::RegisterAddon(#Name, (RegFunc)); \
} \
} _AutoRegisterFor##Name
```
JS 端:`require('MyPlugin')``FindModule` → 执行注册函数 → 获取 exports。
## 类型声明文件
位于 `puerts\unreal\Puerts\Typing\`
| 文件 | 内容 |
|------|------|
| `puerts/index.d.ts` | 核心 Puerts API`$Ref`, `blueprint`, `toDelegate`, `on/off/emit`, `merge`, `load` |
| `ue/puerts.d.ts` | UE 绑定类型:`$Delegate`, `TArray`, `TSet`, `TMap`, `NewObject`, `NewStruct` |
| `ue/puerts_decorators.d.ts` | 装饰器:`rpc.flags()`, `edit_on_instance()`, `uclass`, `ufunction` |
| `ue/index.d.ts` | 聚合所有声明(通过 `/// <reference path="..." />` |
`ue.d.ts``ue_bp.d.ts``DeclarationGenerator` 模块使用 UE 反射系统自动生成。
## 架构图
```
[Game/Editor 启动]
FPuertsModule::StartupModule()
├── RegisterSettings() → 读取 DefaultPuerts.ini
└── Enable()
├── GUObjectArray::AddUObjectCreateListener(this) // 拦截所有 UObject 创建
└── MakeSharedJsEnv()
└── FJsEnv(ScriptRoot) 包装器
└── FJsEnvImpl 构造
├── v8::Isolate::New() + v8::Context::New()
├── 注册全局 C++ 回调 (__tgjs* + puerts.*)
├── ExecuteModule() ×13 (bootstrap JS 文件)
└── 捕获 global.require, global.__reload
└── Start(ModuleName) → require(ModuleName) → 用户代码执行
[运行时: UObject 创建(如 Blueprint 实例化)]
FPuertsModule::NotifyUObjectCreated()
└── JsEnv->TryBindJs(InObject)
├── TypeScriptGeneratedClass? → MakeSureInject (绑定 JS 原型)
└── JSGeneratedClass? → JsConstruct (调用 JS 构造函数)
[C++ 调用 TS]
UFUNCTION(BlueprintEvent) _Implementation → UE VM → execCallJS
→ DynamicInvoker->InvokeJsMethod()
→ FJsEnvImpl::InvokeJsMethod()
→ FunctionTranslator->CallJs() [参数编组]
→ JS 函数被调用
[TS 调用 C++]
require('ue') → Proxy → loadUEType → UETypeToJsClass → DataTransfer::FindOrAddObject
new MyActor() → NewObjectByClass → UObject 创建并调用构造函数
actor.K2_SomeMethod() → UFUNCTION → 原生 C++ 代码执行
```
## 关键文件索引
| 文件 | 用途 |
|------|------|
| `unreal/Puerts/Puerts.uplugin` | 插件定义(模块列表、加载阶段) |
| `Source/Puerts/Public/PuertsModule.h` | `IPuertsModule` 接口 |
| `Source/Puerts/Private/PuertsModule.cpp` | 模块启动/关闭JsEnv 生命周期PIE 钩子 |
| `Source/JsEnv/Public/JsEnv.h` | `FJsEnv` 公开包装器 |
| `Source/JsEnv/Private/JsEnvImpl.h` | `FJsEnvImpl` 完整定义 |
| `Source/JsEnv/Private/JsEnvImpl.cpp` | V8 初始化、bootstrap、模块执行、绑定 |
| `Source/JsEnv/Public/JSModuleLoader.h` | `IJSModuleLoader` 接口 + `DefaultJSModuleLoader` |
| `Source/JsEnv/Private/DefaultJSModuleLoader.cpp` | 文件系统搜索/加载实现 |
| `Source/JsEnv/Public/JsObject.h` | `FJsObject` — JS 函数/对象的 C++ 包装器 |
| `Source/JsEnv/Public/DataTransfer.h` | `DataTransfer` — C++/JS 类型转换 |
| `Source/JsEnv/Public/DynamicInvoker.h` | `IDynamicInvoker` 接口 |
| `Source/JsEnv/Public/TsDynamicInvoker.h` | `ITsDynamicInvoker` 接口 |
| `Source/JsEnv/Public/TypeScriptGeneratedClass.h` | `UTypeScriptGeneratedClass` |
| `Source/JsEnv/Private/JSGeneratedClass.h` | `UJSGeneratedClass` |
| `Source/JsEnv/Private/JSGeneratedFunction.h` | `UJSGeneratedFunction` (magic bytecode) |
| `Source/JsEnv/Private/FunctionTranslator.h` | `FFunctionTranslator` — C++/JS 参数编组 |
| `Source/JsEnv/Public/JSClassRegister.h` | `JSClassDefinition`, `PUERTS_MODULE` 宏 |
| `Source/JsEnv/Public/JsEnvGroup.h` | `FJsEnvGroup` — 多隔离模式 |
| `Content/JavaScript/puerts/modular.js` | JS 端 CommonJS/ESM 模块系统 |
| `Content/JavaScript/puerts/uelazyload.js` | UE 类型懒加载, makeUClass, mixin |
| `Content/JavaScript/puerts/callable.js` | 替代 Proxy callable 模式 |
| `Content/JavaScript/puerts/hot_reload.js` | V8 Inspector 热重载 |
| `Content/JavaScript/puerts/pesaddon.js` | PESAPI 原生 addon 加载器 |

View File

@@ -0,0 +1,693 @@
---
tags:
- UE
- Puerts
- EasyEditorPlugin
- 改造方案
- TypeScript
- 架构
created: 2026-05-31
---
# 改造方案:将业务 TS 代码嵌入插件
## 需求背景
目前 EasyEditorPlugin 将业务 TypeScript 代码放在**项目的 `Content/EasyEditorScripts/` 目录**中,插件本身只是一个薄桥接层。我们希望能够:
1. **业务 TS 代码跟随插件分发**(写在插件的 Content/Resources 目录中)
2. **项目 C++ 代码直接调用插件中的 TS 函数**(类型安全,有返回值)
3. **项目代码只需最少量的调用**LoadModule + 少量 API 调用)
## 可行性评估
| 需求 | 可行性 | 改造量 |
|------|--------|--------|
| TS 代码嵌入插件 Content | ✅ 可行,自定义 IJSModuleLoader | 小(~50 行 C++ |
| C++ 调用插件 TS 函数 | ✅ 可行,暴露 FJsObject 获取 API | 中(~100 行 C++ |
| TS 代码热重载 | ✅ 已有基础设施,仅需调整路径 | 无需改动 |
| 运行时(非 Editor支持 | ✅ 可行,但需创建新 Runtime 模块 | 大(架构变更) |
## 方案一:改造 EasyEditorPlugin编辑器工具场景
适用场景:**编辑器工具扩展**,业务代码是编辑器工具逻辑(如资产处理、关卡自动化等),不需要在打包后的游戏中运行。
### 改造点 1自定义 IJSModuleLoader从插件 Content 加载
#### 当前代码
```cpp
// EasyEditorPlugin.cpp InitJsEnv()
JsEnv = MakeShared<puerts::FJsEnv>(
std::make_shared<puerts::DefaultJSModuleLoader>(TEXT("EasyEditorScripts")),
...);
```
**问题**`DefaultJSModuleLoader` 只在项目 Content 目录搜索,不会搜索插件目录。
#### 改造后
新建 `PluginJSModuleLoader.h`
```cpp
#pragma once
#include "JSModuleLoader.h"
#include "Misc/Paths.h"
class FPluginJSModuleLoader : public PUERTS_NAMESPACE::IJSModuleLoader
{
public:
explicit FPluginJSModuleLoader(const FString& InPluginContentSubDir)
: ScriptRoot(InPluginContentSubDir)
{
// 获取插件 Content 目录
// EasyEditorPlugin 中: FPaths::ProjectPluginsDir() / "EasyEditorPlugin/Content"
PluginContentDir = DeterminePluginContentDir();
}
virtual bool Search(const FString& RequiredDir, const FString& RequiredModule,
FString& Path, FString& AbsolutePath) override
{
// 搜索顺序:
// 1. 先在插件的 Content/ScriptRoot/ 下搜索
// 2. 再走默认的 DefaultJSModuleLoader 逻辑(项目 Content、node_modules 等)
// 插件目录搜索
FString PluginDir = PluginContentDir / ScriptRoot;
if (SearchModuleInDir(PluginDir, RequiredModule, Path, AbsolutePath))
return true;
// 回退到默认搜索逻辑
return DefaultLoader.Search(RequiredDir, RequiredModule, Path, AbsolutePath);
}
virtual bool Load(const FString& Path, TArray<uint8>& Content) override
{
return DefaultLoader.Load(Path, Content);
}
virtual FString& GetScriptRoot() override
{
return ScriptRoot;
}
static FString DeterminePluginContentDir()
{
// 获取当前插件自身的 Content 目录
// 方案 A通过 IPluginManager 查询
TSharedPtr<IPlugin> Plugin = IPluginManager::Get()
.FindPlugin(TEXT("EasyEditorPlugin"));
if (Plugin.IsValid())
{
return Plugin->GetContentDir();
}
// 方案 B手动拼路径兼容旧版本
return FPaths::ProjectPluginsDir() / TEXT("EasyEditorPlugin/Content");
}
private:
FString ScriptRoot;
FString PluginContentDir;
PUERTS_NAMESPACE::DefaultJSModuleLoader DefaultLoader;
bool SearchModuleInDir(const FString& Dir, const FString& RequiredModule,
FString& Path, FString& AbsolutePath)
{
// 尝试扩展名: .js, .mjs, .cjs, .mbc, .cbc, /index.js, package.json
static const TArray<FString> Extensions = {
TEXT(".js"), TEXT(".mjs"), TEXT(".cjs"),
TEXT(".mbc"), TEXT(".cbc")
};
for (const auto& Ext : Extensions)
{
FString TestPath = Dir / RequiredModule + Ext;
if (FPaths::FileExists(TestPath))
{
Path = RequiredModule + Ext;
AbsolutePath = FPaths::ConvertRelativePathToFull(TestPath);
return true;
}
}
// 尝试目录 + index.js
FString IndexPath = Dir / RequiredModule / TEXT("index.js");
if (FPaths::FileExists(IndexPath))
{
Path = RequiredModule / TEXT("index.js");
AbsolutePath = FPaths::ConvertRelativePathToFull(IndexPath);
return true;
}
return false;
}
};
```
#### 修改 InitJsEnv()
```cpp
void FEasyEditorPluginModule::InitJsEnv()
{
SourceFileWatcher = MakeShared<puerts::FSourceFileWatcher>(
[this](const FString& InPath) { /* 同上 */ });
// 使用自定义 Loader从插件 Content/BusinessScripts/ 加载
JsEnv = MakeShared<puerts::FJsEnv>(
std::make_shared<FPluginJSModuleLoader>(TEXT("BusinessScripts")),
std::make_shared<puerts::FDefaultLogger>(), -1,
[this](const FString& InPath) { /* 同上 */ });
}
```
#### 插件目录结构变为
```
EasyEditorPlugin/
├── Content/
│ └── BusinessScripts/ ← TS 业务代码(随插件分发)
│ ├── Main.ts ← 入口
│ ├── commands/
│ │ ├── AssetCommands.ts
│ │ └── LevelCommands.ts
│ ├── ui/
│ │ ├── MenuSetup.ts
│ │ └── DetailPanel.ts
│ └── utils/
│ └── helpers.ts
├── Source/
│ └── ...
└── EasyEditorPlugin.uplugin
```
---
### 改造点 2暴露 JsEnv支持 C++ 类型安全调用 TS
#### 问题
```cpp
class FEasyEditorPluginModule {
private:
TSharedPtr<puerts::FJsEnv> JsEnv; // ← 外部无法访问
};
```
外部 C++ 只能通过 `Eval` 执行 JS 字符串,无类型安全、无返回值。
#### 改造:公开 JsEnv 访问和 FJsObject 获取
`EasyEditorPlugin.h` 中新增:
```cpp
class FEasyEditorPluginModule : public IModuleInterface
{
public:
// ===== 新增 API =====
/**
* 从 JS 全局作用域获取一个函数/对象,返回 FJsObject 用于类型安全调用
* 示例:
* auto& Mod = FModuleManager::LoadModuleChecked<FEasyEditorPluginModule>("EasyEditorPlugin");
* FJsObject MyFunc = Mod.GetJsExport("myBusinessFunction");
* int32 Result = MyFunc.Func<int32, int32, FString>(42, TEXT("hello"));
*/
FJsObject GetJsExport(const FString& Name) const;
/**
* 从 JS 模块(已 require 的)中获取一个导出
* 示例:
* FJsObject Handler = Mod.GetJsModuleExport("commands/AssetCommands", "handleAsset");
* Handler.Action(TEXT("/Game/MyAsset"));
*/
FJsObject GetJsModuleExport(const FString& ModuleName, const FString& ExportName) const;
/**
* 获取底层的 IJsEnv 接口(高级用法)
*/
TSharedPtr<puerts::FJsEnv> GetJsEnv() const { return JsEnv; }
// ===== 原有 API =====
std::function<void()> OnJsEnvPreReload;
std::function<void(const FString&)> Eval;
FSimpleMulticastDelegate OnJsEnvCleanup;
TSparseArray<TUniquePtr<FAutoConsoleCommand>> TsConsoleCommands;
private:
// ...
};
```
#### 实现(`EasyEditorPlugin.cpp`
```cpp
FJsObject FEasyEditorPluginModule::GetJsExport(const FString& Name) const
{
if (!JsEnv.IsValid())
{
UE_LOG(Puerts, Error, TEXT("JsEnv is not valid when getting export '%s'"), *Name);
return {};
}
// 通过 Eval 机制获取全局变量
// 实际实现需要利用 Puerts 的内部 API
// 方案见下方"实现细节"
}
FJsObject FEasyEditorPluginModule::GetJsModuleExport(
const FString& ModuleName, const FString& ExportName) const
{
if (!JsEnv.IsValid())
{
return {};
}
// 需要从模块缓存中查找
// 方案见下方"实现细节"
}
```
#### 实现细节:如何从 JsEnv 中获取 JS 对象
**核心问题**`FJsEnv``FJsEnvImpl` 成员是私有的,没有公开的 "通过名称获取 JS 变量" API。需要通过以下方式实现
**方案 A通过 `puerts::FJsEnv` 包装器暴露新方法**
需要在 Puerts 的 `FJsEnv` 中新增一个方法:
```cpp
// 在 JsEnv.h 中新增
class JSENV_API FJsEnv
{
public:
// ... 现有 API ...
/**
* 获取全局作用域中的一个值
* @param Name 全局变量/函数名
* @return 包装了该 JS 值的 FJsObject如果不存在或类型不对则返回空
*/
FJsObject GetGlobalValue(const FString& Name);
};
```
实现(在 `JsEnvImpl.cpp` 中):
```cpp
FJsObject FJsEnv::GetGlobalValue(const FString& Name)
{
if (!GameScript) return {};
auto Impl = static_cast<FJsEnvImpl*>(GameScript.get());
auto Isolate = Impl->MainIsolate;
auto Context = Impl->DefaultContext.Get(Isolate);
#ifdef THREAD_SAFE
v8::Locker Locker(Isolate);
#endif
v8::Isolate::Scope IsolateScope(Isolate);
v8::HandleScope HandleScope(Isolate);
v8::Context::Scope ContextScope(Context);
auto Global = Context->Global();
auto V8Name = FV8Utils::ToV8String(Isolate, Name);
auto MaybeValue = Global->Get(Context, V8Name);
v8::Local<v8::Value> Val;
if (MaybeValue.ToLocal(&Val) && Val->IsObject())
{
return FJsObject(Context, Val.As<v8::Object>());
}
return {};
}
```
但这种方式需要 **修改 Puerts 源码**(在 `JsEnv.h``JsEnvImpl.cpp` 中增加代码),对于已经安装的 Puerts 插件不太方便。
**方案 B在 EasyEditorPlugin 中注入 JS 辅助函数(推荐,无需修改 Puerts**
思路:在 `InitJsEnv()` 时,向 JS 全局注入一个 `__eep_getExport` 函数,同时在 C++ 中存储引用。
```cpp
// EasyEditorPlugin.cpp
// 存储注入的 JS 辅助函数
static FJsObject G_GetExportFunc;
void FEasyEditorPluginModule::InitJsEnv()
{
// ... 创建 JsEnv ...
JsEnv->Start(TEXT("Main"));
// Main.ts 执行后,获取注入的辅助函数引用
// Main.ts 中需要暴露:
// global.__eep_getExport = (name) => { ... return eval(name); }
// global.__eep_getModuleExport = (moduleName, exportName) => { ... }
}
```
但这形成了一个鸡生蛋蛋生鸡的问题 —— 在 Main.ts 执行前无法获取引用。
**方案 C使用 Puerts 的 JIT stub 机制(最务实)**
在 EasyEditorPlugin 的绑定代码中注册可被 `require()` 调用的 C++ 函数:
```cpp
// 在 EasyEditorPlugin.cpp 的 AutoRegisterForEEP 中新增绑定:
struct FEasyEditorBridge
{
static puerts::Function GetGlobalFunction;
static FJsObject GetGlobalObject;
// 注册到 Puerts binding
};
```
然后在 Main.ts 中:
```typescript
// 将需要暴露给 C++ 的函数注册到 global 对象
import { MyBusinessLogic } from './business/BusinessLogic';
(global as any).__exports = {
processAsset: MyBusinessLogic.processAsset,
validateLevel: MyBusinessLogic.validateLevel,
// ...
};
```
C++ 侧通过 Eval 获取:
```cpp
FJsObject FEasyEditorPluginModule::GetJsExport(const FString& Name) const
{
// 利用 Eval 机制代理
// 实际方案:在 V8 中执行 "__exports[Name]",捕获结果
// 但当前 Eval 是 void 返回的...
// 需要改造 Eval 为有返回值的版本,见下文
}
```
**方案 D改造 Eval 为有返回值的版本(最小侵入)**
```cpp
// 在 EasyEditorPlugin.h 中
class FEasyEditorPluginModule
{
public:
// 替代原来的 void Eval使用可返回值的版本
std::function<FJsObject(const FString&)> EvalWithReturn;
};
```
`InitJsEnv()` 中注册对应的 C++ 绑定:
```cpp
// 利用 Puerts 的 FV8Utils 实现有返回值的 Eval
// 在 C++ 侧: 持有 v8::Isolate 和 v8::Context直接 eval 并封装结果
```
**方案 E最简单且实用的方案 —— 通过 Console 命令 + 注册回调**
不暴露 JsEnv而是让项目 C++ 注册一个回调TS 通过 `EasyEditorPlugin.AddConsoleCommand` 或其他绑定来调用它:
```cpp
// 项目 C++ 代码中使用 EasyEditorPlugin
void FMyProjectModule::StartupModule()
{
auto& EEP = FModuleManager::LoadModuleChecked<FEasyEditorPluginModule>("EasyEditorPlugin");
// 在 JS 环境就绪后注册
EEP.OnJsEnvCreated.AddLambda([this]() {
// 告诉 TS 侧C++ 准备好了
EEP.Eval(TEXT("if (global.__onCppReady) global.__onCppReady()"));
});
}
```
但这种模式是 **TS 调用 C++**,而非用户需求的 **C++ 调用 TS**
---
### 推荐的 C++ → TS 调用方案总结
| 方案 | 侵入性 | 类型安全 | 需要修改 Puerts | 推荐度 |
|------|--------|---------|----------------|--------|
| A: 扩展 FJsEnv::GetGlobalValue | 低 | ✅ | ❌ 需要 | ⭐⭐⭐ |
| B: 注入 JS 辅助函数 | 中 | ✅ | 不需要 | ⭐⭐ |
| C: require() 注册 | 中 | ✅ | 不需要 | ⭐⭐ |
| D: Eval 改造成有返回值 | 低 | ❌ 需手动转换 | 不需要 | ⭐⭐ |
| E: 回调注册 | 低 | ❌ | 不需要 | ⭐(反向模式) |
**最佳实践:方案 CPUERTS_MODULE addon+ 方案 A扩展 FJsEnv**
在实际项目中,建议**对 Puerts 做少量扩展**fork 或 patch因为 Puerts 本身设计就预留了扩展点。
---
## 方案二:基于 Puerts 创建自己的 Runtime 插件(运行时代码场景)
如果业务代码需要在**打包后的游戏**中运行Gameplay 逻辑、AI、战斗系统等EasyEditorPlugin 的 Editor-only 限制是致命的。
此时正确的做法是:**直接基于 Puerts 创建自己的 Runtime 插件**。
### 插件骨架
```
MyBusinessPlugin/
├── MyBusinessPlugin.uplugin
├── Content/
│ └── BusinessScripts/ ← TS 业务代码
│ ├── Main.ts ← 入口
│ ├── combat/
│ ├── ai/
│ └── ui/
└── Source/
└── MyBusinessPlugin/
├── MyBusinessPlugin.Build.cs
├── Public/
│ └── MyBusinessPluginModule.h
│ └── BusinessAPI.h ← 公开给项目 C++ 的 API
└── Private/
├── MyBusinessPluginModule.cpp
└── MyBinding.cpp ← PUERTS_MODULE 注册
```
### .uplugin
```json
{
"FriendlyName": "MyBusinessPlugin",
"Modules": [{
"Name": "MyBusinessPlugin",
"Type": "Runtime", // ← Runtime可在打包游戏中运行
"LoadingPhase": "Default"
}],
"Plugins": [{
"Name": "Puerts",
"Enabled": true
}]
}
```
### Build.cs
```csharp
PublicDependencyModuleNames.AddRange(new string[] {
"Core",
"CoreUObject",
"Engine",
"JsEnv" // ← 依赖 Puerts 的 JsEnv
});
```
### `MyBusinessPluginModule.h`
```cpp
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
#include "JsEnv.h"
#include "JsObject.h"
class FMyBusinessPluginModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
// 公开给项目 C++ 的 API
FJsObject GetExport(const FString& Name) const;
TSharedPtr<puerts::FJsEnv> GetJsEnv() const { return JsEnv; }
private:
TSharedPtr<puerts::FJsEnv> JsEnv;
void InitJsEnv();
void UnInitJsEnv();
};
```
### `MyBusinessPluginModule.cpp`
```cpp
#include "MyBusinessPluginModule.h"
#include "JSModuleLoader.h"
#include "Misc/Paths.h"
#include "Interfaces/IPluginManager.h"
void FMyBusinessPluginModule::StartupModule()
{
InitJsEnv();
}
void FMyBusinessPluginModule::ShutdownModule()
{
UnInitJsEnv();
}
void FMyBusinessPluginModule::InitJsEnv()
{
// 获取插件 Content 目录
TSharedPtr<IPlugin> Plugin = IPluginManager::Get()
.FindPlugin(TEXT("MyBusinessPlugin"));
FString PluginContent = Plugin.IsValid()
? Plugin->GetContentDir()
: FPaths::ProjectPluginsDir() / TEXT("MyBusinessPlugin/Content");
// 创建自定义 ModuleLoader从插件 Content/BusinessScripts/ 加载
auto Loader = MakeShared<FPluginJSModuleLoader>(
PluginContent / TEXT("BusinessScripts"));
JsEnv = MakeShared<puerts::FJsEnv>(
Loader,
MakeShared<puerts::FDefaultLogger>(),
-1, // debugPort, -1 = 禁用调试
nullptr,
FString(),
nullptr, nullptr);
// 启动业务代码入口
JsEnv->Start(TEXT("Main"));
}
void FMyBusinessPluginModule::UnInitJsEnv()
{
JsEnv.Reset();
}
FJsObject FMyBusinessPluginModule::GetExport(const FString& Name) const
{
// 实现同方案一的讨论
// 推荐方式: 对 Puerts 做 patch给 FJsEnv 加 GetGlobalValue
}
```
### `BusinessAPI.h` — 给项目 C++ 的调用接口
```cpp
#pragma once
#include "CoreMinimal.h"
#include "JsObject.h"
namespace BusinessAPI
{
// 获取插件的 JsEnv
TSharedPtr<PUERTS_NAMESPACE::FJsEnv> GetBusinessJsEnv();
// 获取 JS 导出函数
PUERTS_NAMESPACE::FJsObject GetExport(const FString& Name);
// 便捷调用封装
inline int32 ValidateLevel(const FString& LevelPath)
{
auto Func = GetExport(TEXT("validateLevel"));
if ( /* Func is valid */ )
{
return Func.Func<int32, FString>(LevelPath);
}
return -1;
}
}
```
### 项目 C++ 调用方式
```cpp
// 项目 C++ 代码 —— 极简调用
void AMyGameMode::BeginPlay()
{
Super::BeginPlay();
// 加载插件(自动启动 TS 业务逻辑)
FModuleManager::Get().LoadModule("MyBusinessPlugin");
// 调用 TS 函数
auto Func = BusinessAPI::GetExport(TEXT("onGameStart"));
Func.Action(this); // 传递 UObject自动转换为 TS 侧 UE 类型
}
```
---
## 热重载支持
无论方案一还是方案二,热重载都是可用的。只需确保 `FSourceFileWatcher` 监听的是正确的目录:
```cpp
// 插件 Content 目录下的 BusinessScripts/
FString WatchDir = PluginContentDir / TEXT("BusinessScripts");
SourceFileWatcher = MakeShared<puerts::FSourceFileWatcher>(
[this](const FString& InPath) {
if (JsEnv.IsValid()) {
TArray<uint8> Source;
if (FFileHelper::LoadFileToArray(Source, *InPath)) {
JsEnv->ReloadSource(InPath,
puerts::PString((const char*)Source.GetData(), Source.Num()));
}
}
});
```
---
## 改造清单
### 最小改造(编辑器工具,方案一)
| 步骤 | 文件 | 改动 |
|------|------|------|
| 1 | 新建 `PluginJSModuleLoader.h` | 自定义模块加载器,从插件 Content 搜索 |
| 2 | `EasyEditorPlugin.cpp:InitJsEnv()` | 切换为 `FPluginJSModuleLoader` |
| 3 | `EasyEditorPlugin.h` | 添加 `GetJsEnv()``GetJsExport()` |
| 4 | 插件 `Content/BusinessScripts/` | 移入 TS 业务代码 |
| 5 | (可选) Puerts `JsEnv.h/cpp` patch | 添加 `GetGlobalValue()` 方法 |
### 完整改造Runtime 支持,方案二)
| 步骤 | 文件 | 改动 |
|------|------|------|
| 1 | 创建新插件 `MyBusinessPlugin` | `.uplugin`, `Build.cs`, 目录结构 |
| 2 | `PluginJSModuleLoader.h`(复用) | 自定义模块加载器 |
| 3 | `MyBusinessPluginModule.h/cpp` | 模块实现,参考 EasyEditorPlugin |
| 4 | `BusinessAPI.h` | 公开 C++ 调用 API |
| 5 | 插件 `Content/BusinessScripts/` | 移入 TS 业务代码 |
| 6 | Puerts `JsEnv.h/cpp` patch | 添加 `GetGlobalValue()` 方法 |
| 7 | 项目 `.uproject` | 添加插件依赖 |
---
## 注意事项
1. **线程安全**FJsObject 在 `THREAD_SAFE` 模式下自动加锁,但建议所有 TS 调用都在 GameThread 进行
2. **JsEnv 生命周期**:确保在 `ShutdownModule()` 中正确销毁 JsEnv避免 V8 isolate 泄漏
3. **GC 联动**:参考 EasyEditorPlugin 的地图卸载时 GC 处理,避免 "GC Memory Leak" 断言
4. **模块解析**:自定义 `IJSModuleLoader` 需要正确实现 `Search``Load` 两个方法
5. **Puerts 版本**:不同 Puerts 版本的 V8 API 可能不同V8 8.x vs 11.x注意 `V8_MAJOR_VERSION` 条件编译

View File

@@ -0,0 +1,656 @@
---
tags:
- Lyra
- UE5
- GAS
- 学习笔记
- GameFeatures
created: 2026-05-31
---
# Lyra Starter Game 系统学习指南
> 从零开始理解 Lyra 的逻辑主干、GameModule vs GameFeature、以及如何迭代开发
---
## 一、Lyra 整体逻辑架构 — 游戏从启动到运行的全貌
学 Lyra 最大的障碍不是某个类复杂,而是"不知道这些类是怎么串起来的"。这一章先画地图,再看路标。
### 1.1 架构分层:三层金字塔
```
┌─────────────────────────────────────────────────────────┐
│ 第三层GameFeature │
│ ShooterCore / TopDownArena / ShooterExplorer │
玩法内容DataAsset + 蓝图 + 地图 + 少量专用 C++
│ 职责:"这个游戏模式有什么技能按键怎么绑UI什么样" │
│ 加载方式:运行时按需加载,通过 Experience 触发 │
├─────────────────────────────────────────────────────────┤
│ 第二层LyraGame │
│ GameModuleC++ 编译单元,启动时强制加载) │
游戏框架ASC、Ability、AttributeSet、Character、输入
│ 职责:"提供所有 GAS 基类、GameFeatureAction 机制、 │
│ Pawn 扩展系统、Experience 管理、UI 框架" │
├─────────────────────────────────────────────────────────┤
│ 第一层UE5 引擎 + 插件 │
│ Engine: GameplayAbilities / EnhancedInput / GameFeatures│
│ Engine: ModularGameplay / CommonUI / GameplayMessageRouter│
│ 职责:"提供 GAS 核心、输入框架、Feature 加载/卸载" │
└─────────────────────────────────────────────────────────┘
```
**依赖方向**GameFeature → LyraGame → Engine。上层依赖下层永不反向。
### 1.2 启动流程全景图:谁先动、谁跟着动
这是整个 Lyra 最重要的时序图,看懂了这张图就看懂了 Lyra 的一半:
```
┌─ 阶段0引擎启动 ─────────────────────────────────────────┐
│ UE5 引擎启动 │
│ → 加载 LyraGame.dllGameModule不可卸载
│ → 所有 UCLASS 注册到反射系统 │
│ → ULyraGameEngineSubsystem::Initialize() │
│ → UGameFeaturesSubsystem 初始化 │
└───────────────────────────────────────────────────────────┘
┌─ 阶段1进入地图 ─────────────────────────────────────────┐
│ World 创建 │
│ → GameState 创建 → ULyraExperienceManagerComponent 创建 │
│ → GameMode 创建 → 调用 ChoosePlayerStart 等 │
│ │
│ 重点:此时还没有加载任何 GameFeature。 │
│ 只有 LyraGame 的 C++ 类在内存中。 │
└───────────────────────────────────────────────────────────┘
┌─ 阶段2Experience 加载(最关键!)────────────────────────┐
│ GameMode::InitGame() │
│ → ExperienceManager->SetCurrentExperience(ExperienceId) │
│ → StartExperienceLoad() │
│ │
│ 2a. 加载 Experience DataAsset │
│ Experience.GameFeaturesToEnable = [ │
│ "ShooterCore", │
│ "ShooterMaps" │
│ ] │
│ │
│ 2b. 逐个加载 GameFeature Plugin │
│ UGameFeaturesSubsystem::LoadPlugin("ShooterCore") │
│ → 加载 ShooterCore.dll如果有 C++
│ → 扫描 ShooterCore 的 Content 目录 │
│ → 注册其中的所有 UGameFeatureAction │
│ → 调用每个 Action 的 OnGameFeatureRegistering() │
│ │
│ 2c. 激活 GameFeature │
│ 对每个 Action 调用 OnGameFeatureActivating(): │
│ │
│ GameFeatureAction_AddInputContextMapping │
│ → 把 IMC_ShooterGame 注册到 EnhancedInput 子系统 │
│ │
│ GameFeatureAction_AddInputBinding │
│ → 把 InputData_ShooterGame_AddOns 绑定到输入系统 │
│ │
│ GameFeatureAction_AddAbilities │
│ → 向 GameFrameworkComponentManager 注册: │
│ "当 ALyraCharacter 创建时,自动给它这些技能" │
│ │
│ GameFeatureAction_AddWidgets │
│ → 注册 Shooter 专用 HUD Widget │
│ │
│ ★ 注意:这时候还没有玩家加入,只是"注册了规则" │
│ │
│ 2d. 广播 Experience 加载完成 │
│ Broadcast OnExperienceLoaded │
└───────────────────────────────────────────────────────────┘
┌─ 阶段3玩家加入 + Pawn 生成 ──────────────────────────────┐
│ Login → APlayerController 创建 │
│ → ALyraPlayerState 创建(这是关键对象!) │
│ → 构造函数: ASC = CreateDefaultSubobject (ASC 诞生) │
│ → 构造函数: HealthSet = CreateDefaultSubobject │
│ → 构造函数: CombatSet = CreateDefaultSubobject │
│ → PostInitializeComponents: │
│ → ASC->InitAbilityActorInfo(this, GetPawn()) │
Owner=PlayerState, Avatar=暂无/旧Pawn
│ → 注册 OnExperienceLoaded 回调 │
│ │
│ → 如果 Experience 已加载: │
│ → OnExperienceLoaded() │
│ → GameMode::GetPawnDataForController() │
│ → SetPawnData(PawnData) │
│ → 遍历 PawnData->AbilitySets │
│ → AbilitySet->GiveToAbilitySystem(ASC) ← 技能注入!│
│ → Broadcast NAME_LyraAbilityReady │
│ → 触发 GameFeatureAction_AddAbilities 的 │
│ HandleActorExtension为 PlayerState 注入 │
│ GameFeature 专属技能) │
│ │
│ → RestartPlayer() │
│ → Pawn 创建 (ALyraCharacter) │
│ → Possess(Pawn) │
│ → ULyraHeroComponent::OnPawnReadyToInitialize() │
│ → PawnExtComp->InitializeAbilitySystem( │
│ PS->GetASC(), PS) ← ASC 桥接到新 Pawn
│ → ASC->InitAbilityActorInfo(PlayerState, Pawn) │
Owner=PlayerState, Avatar=新Pawn
│ → 设置 TagRelationshipMapping │
│ → Broadcast OnAbilitySystemInitialized │
│ │
│ → PlayerController 绑定输入 │
│ → LyraHeroComponent 绑定 EnhancedInput │
│ → InputConfig 映射 InputAction → InputTag │
│ → 玩家可以操作了 │
└───────────────────────────────────────────────────────────┘
```
**核心理解**:三个阶段是**异步链式触发**的,不是硬编码顺序。机制是:
- Experience 加载完 → 广播 `OnExperienceLoaded` → PlayerState 收到 → 执行 SetPawnData
- Pawn 生成 + 输入绑定 → 广播 `InitState.GameplayReady` → 所有组件就绪
### 1.3 各模块入口速查:从哪里开始读
按关注领域,给出每个模块的**入口文件**和**为什么从这里开始**
| 关注领域 | 入口文件 | 理由 |
|----------|----------|------|
| **整个游戏的启动** | `LyraExperienceManagerComponent.cpp` | `SetCurrentExperience()``StartExperienceLoad()` 是唯一入口 |
| **Experience 是什么** | `LyraExperienceDefinition.h` | 只有 3 个字段GameFeaturesToEnable, DefaultPawnData, Actions |
| **GameFeature 如何注入能力** | `GameFeatureAction_AddAbilities.cpp` | `AddToWorld()``HandleActorExtension()``AddActorAbilities()` 完整链路 |
| **ASC 桥接Pawn 切换)** | `LyraPawnExtensionComponent.cpp` | `InitializeAbilitySystem()``UninitializeAbilitySystem()` 是核心 |
| **Pawn 和 PlayerState 如何粘合** | `LyraHeroComponent.cpp` | `OnPawnReadyToInitialize()` 中的 `InitializeAbilitySystem(PS->GetASC(), PS)` |
| **输入如何变成技能** | `LyraAbilitySystemComponent.h` | 搜索 `AbilityInputTagPressed` + `ProcessAbilityInput` |
| **技能从哪来** | `LyraAbilitySet.cpp` | `GiveToAbilitySystem()``TakeFromAbilitySystem()` |
| **属性怎么设计** | `LyraHealthSet.h` | 先学 HealthSet4属性再学 CombatSet2属性 |
| **伤害怎么算** | `LyraDamageExecution.cpp` | `CalculateDamage_Implementation()``Capture` 机制 |
| **网络怎么同步** | `LyraPlayerState.cpp` | `GetLifetimeReplicatedProps()` 看复制了什么 |
| **AI/Bot 的 ASC** | `LyraCharacterWithAbilities.h` | 独立 ASC 的自给自足模式 |
| **GameFeature 测试怎么测** | `ShooterTests/` | 看自动化测试如何验证 Feature 加载 |
| **UI 框架** | `GameFeatureAction_AddWidgets.cpp` | 看 Widget 如何通过 GameFeature 注入 |
| **游戏阶段** | `LyraGamePhaseSubsystem.cpp` | 看 WorldSubsystem 如何管理阶段状态 |
### 1.4 LyraGame 模块内部组织
`Source/LyraGame/` 目录按功能分为 14 个文件夹。你不必全部吃透,按关注度分三级:
**红色必读**GAS + Gameplay 核心):
```
AbilitySystem/
├── LyraAbilitySystemComponent.h/cpp ← ASC 扩展(输入缓冲 + ActivationGroup
├── LyraAbilitySystemGlobals.h/cpp ← 全局 GAS 配置
├── LyraGameplayEffectContext.h/cpp ← 扩展 EffectContext
├── LyraAbilitySet.h/cpp ← 技能打包 DataAsset
├── LyraAbilityTagRelationshipMapping.h/cpp ← Tag 互斥关系
├── LyraGlobalAbilitySystem.h/cpp ← 全局技能分发 WorldSubsystem
├── Abilities/
│ ├── LyraGameplayAbility.h/cpp ← 技能基类
│ └── LyraAbilityCost*.h/cpp ← 可扩展 Cost 系统
├── Attributes/
│ ├── LyraAttributeSet.h/cpp ← 属性集基类
│ ├── LyraHealthSet.h/cpp ← 生命值+Meta属性管线
│ └── LyraCombatSet.h/cpp ← 战斗属性
├── Executions/
│ ├── LyraDamageExecution.h/cpp ← 伤害计算
│ └── LyraHealExecution.h/cpp ← 治疗计算
└── Phases/
├── LyraGamePhaseAbility.h/cpp ← 游戏阶段 Ability
└── LyraGamePhaseSubsystem.h/cpp ← 阶段管理器
Character/
├── LyraCharacter.h/cpp ← Character 基类(委托 ASC
├── LyraCharacterWithAbilities.h/cpp ← AI/Bot 独立 ASC 版本
├── LyraHeroComponent.h/cpp ← 玩家 Hero 粘合层 ★ 关键
├── LyraPawnExtensionComponent.h/cpp ← ASC 桥接层 ★ 关键
└── LyraPawnData.h ← Pawn 配置 DataAsset
Player/
├── LyraPlayerState.h/cpp ← ASC 持有者 ★ 关键
└── LyraPlayerController.h/cpp ← 玩家控制器
```
**橙色按需**(输入/GameFeature/UI
```
Input/
├── LyraInputConfig.h ← InputTag 映射 DataAsset
└── LyraInputModifiers.h/cpp ← 输入修饰器
GameFeatures/
├── GameFeatureAction_AddAbilities.h/cpp ← 能力注入 ★ 关键
├── GameFeatureAction_AddInputBinding.h/cpp
├── GameFeatureAction_AddInputContextMapping.h/cpp
├── GameFeatureAction_AddWidgets.cpp
├── GameFeatureAction_WorldActionBase.h/cpp ← Action 基类
└── GameFeatureAction_SplitscreenConfig.cpp
GameModes/
├── LyraExperienceDefinition.h ← Experience DataAsset ★ 关键
├── LyraExperienceManagerComponent.h/cpp ← Experience 加载器 ★ 关键
└── LyraGameMode.h/cpp ← GameMode 基类
UI/
├── LyraHUD.h/cpp ← HUD 布局
└── LyraHUDLayout.h/cpp ← Widget 堆栈管理
```
**灰色可选**(辅助系统):
```
System/ ← GameplayTagStack、LyraGameData、Significance 等
Teams/ ← 队伍系统
Messages/ ← VerbMessage 消息系统
Weapons/ ← 武器 Actor + 武器状态机
Equipment/ ← 装备管理器 + 装备实例
Camera/ ← 摄像机模式
Performance/ ← 性能统计
Interaction/ ← 交互接口
Cosmetics/ ← 外观系统(皮肤/角色模型)
```
### 1.5 初始化状态机InitState
Lyra 不用 BeginPlay 的硬编码顺序,而是通过 `IGameFrameworkInitStateInterface` 实现状态机:
```
Spawned ← BeginPlay 后立即进入
CanChangeInitState(DataAvailable) ?
↓ Yes
DataAvailable ← 组件数据已加载PawnData Replicated
CanChangeInitState(DataInitialized) ?
→ "PawnExtensionComponent 说 ASC 初始化完成了?"
→ "HeroComponent 说输入准备好了?"
↓ Yes
DataInitialized ← 所有组件初始化完成
CanChangeInitState(GameplayReady) ?
→ "所有 Feature Component 准备好了?"
↓ Yes
GameplayReady ← 可以开始游戏了
```
这个机制的关键在于 `UGameFrameworkComponentManager`——它是来自 `ModularGameplay` 插件的全局注册中心。每个组件通过 `AddExtensionHandler` 注册自己关心哪些 Actor 类型,当该类 Actor 创建或状态变化时自动通知。
---
## 零、先搞清楚GameModule 和 GameFeature 到底有什么区别
这是最容易困惑的地方。一句话:
**GameModule.Build.cs是 C++ 的编译单元。GameFeature.uplugin + UGameFeatureAction是游戏内容的加载/卸载单元。**
| | GameModule | GameFeature Plugin |
|---|---|---|
| **本质** | C++ 编译单元,定义链接依赖 | 游戏功能的加载/卸载容器 |
| **粒度** | 大(整个 .dll| 小(一个玩法模式) |
| **加载时机** | 引擎启动时强制加载 | 运行时按需加载(通过 Experience |
| **卸载** | 不可卸载 | 可以卸载(切换模式时) |
| **包含什么** | C++ 类ASC、AttributeSet、Character | DataAsset + 蓝图 + 少量 C++ + 地图 |
| **依赖方向** | GameFeature 依赖 GameModule | 不反向 |
**Lyra 具体例子**
```
LyraGameGameModule — 编译时加载)
│ 定义基础设施ULyraAbilitySystemComponent、ULyraGameplayAbility、
│ ULyraHealthSet、ULyraAbilitySet、UGameFeatureAction_AddAbilities...
│ 这些东西永远在内存中,不可卸载。
├── ShooterCoreGameFeature Plugin — 运行时加载)
│ │ 没有 C++ 基类定义,只有:
│ │ - AbilitySet_ShooterHeroDataAsset有哪些技能
│ │ - IMC_ShooterGameInputMappingContext按键绑定
│ │ - InputData_ShooterGame_AddOnsInputConfig按键→Tag映射
│ │ - UAimAssistInputModifier少量专用 C++
│ │ - 武器蓝图、地图、Experience 定义
│ │
│ │ 加载时:通过 UGameFeatureAction_AddAbilities 把 AbilitySet
│ │ 里的技能"注入"到 PlayerState 的 ASC 上。
│ │ 卸载时:全部回收。
└── TopDownArenaGameFeature Plugin — 运行时加载)
完全不同的玩法模式,不同 AbilitySet、不同 InputConfig。
可以和 ShooterCore 共存或切换,互不依赖。
```
**核心理解**ShooterCore 自己**没有定义任何新的 GAS 类**。它只是"使用" LyraGame 中已经定义好的 `ULyraAbilitySet``ULyraInputConfig``GameFeatureAction` 这些基础设施,通过**数据配置**DataAsset + 蓝图)来组织射击游戏的特定内容。
---
## 二、逻辑主干 — Lyra 的 5 个核心流程
学 Lyra 最重要的不是记类名,而是理解这 5 个流程。每个流程是一条"主干链路",挂载着相关的"枝叶类"。
### 流程 1游戏启动 → Experience 加载
```
服务器进入地图
→ ULyraExperienceManagerComponent::SetCurrentExperience(ExperienceId)
→ StartExperienceLoad()
→ 加载 Experience 对应的 PrimaryAsset
→ 遍历 Experience.GameFeaturesToEnable
→ UGameFeaturesSubsystem::LoadPlugin(每个Feature)
→ OnGameFeaturePluginLoadComplete() ← 等所有Feature加载完
→ OnExperienceFullLoadCompleted()
→ 执行 Experience.ActionsUGameFeatureAction 列表)
→ GameFeatureAction_AddAbilities::OnGameFeatureActivating()
→ GameFeatureAction_AddInputContextMapping::OnGameFeatureActivating()
→ GameFeatureAction_AddInputBinding::OnGameFeatureActivating()
→ Broadcast OnExperienceLoaded → PlayerState 开始授权技能
```
**关键文件**
- `LyraExperienceManagerComponent.h/cpp` — 整个游戏的启动器
- `LyraExperienceDefinition.h` — 纯 DataAsset定义了"启用哪些Feature + 用哪个 Pawn + 哪些 Action"
- `GameFeatureAction_AddAbilities.h/cpp` — 把 Ability/Attribute/AbilitySet 注入 ASC
**学习顺序**:先看懂 `LyraExperienceDefinition`只有3个字段再看 `LyraExperienceManagerComponent` 的状态机Unloaded → Loading → LoadingGameFeatures → ExecutingActions → Loaded最后看 `GameFeatureAction_AddAbilities::AddToWorld`
### 流程 2PlayerState 初始化 → 技能授权
```
ALyraPlayerState::PostInitializeComponents()
→ ASC->InitAbilityActorInfo(this, GetPawn()) ← Owner=PlayerState, Avatar=Pawn
→ 注册 OnExperienceLoaded 回调
Experience 加载完成
→ OnExperienceLoaded()
→ SetPawnData(PawnData)
→ 遍历 PawnData->AbilitySets
→ AbilitySet->GiveToAbilitySystem(ASC)
→ 逐个 GiveAbility注入 InputTag 到 DynamicSpecSourceTags
→ 逐个 ApplyGameplayEffect初始化属性
→ 逐个 AddAttributeSetSubobject
→ Broadcast NAME_LyraAbilityReady
→ 触发 GameFeatureAction_AddAbilities::HandleActorExtension
为所有已注册ActorClass授予GameFeature专属能力
```
**关键文件**
- `LyraPlayerState.h/cpp` — 拥有 ASC是"数据的主人"
- `LyraPawnData.h` — DataAsset定义"这个Pawn类型用哪些 AbilitySet + 哪个 TagRelationshipMapping"
- `LyraAbilitySet.h/cpp` — 不可变 DataAsset包含 Abilities + Effects + AttributeSets
**学习顺序**`LyraAbilitySet` 的结构 → `GiveToAbilitySystem` 函数 → `LyraPawnData``LyraPlayerState::SetPawnData`
### 流程 3Pawn 生成 → ASC 桥接
```
GameMode::RestartPlayer()
→ Pawn 被创建
→ Possess(Pawn)
→ ULyraHeroComponent::OnPawnReadyToInitialize()
→ PawnExtComp->InitializeAbilitySystem(PS->GetASC(), PS)
ULyraPawnExtensionComponent::InitializeAbilitySystem(InASC, InOwnerActor)
1. 如果已有 ASC → UninitializeAbilitySystem清理旧绑定
2. 如果 ASC 已有其他 Avatar → 踢掉旧的
3. AbilitySystemComponent = InASC ← 瞬态缓存,不拥有
4. InASC->InitAbilityActorInfo(InOwnerActor, Pawn)
5. 设置 ASC 的 TagRelationshipMapping来自 PawnData
6. Broadcast OnAbilitySystemInitialized
→ ALyraCharacter::OnAbilitySystemInitialized()
→ HealthComponent->InitializeWithAbilitySystem()
→ InitializeGameplayTags()
```
**关键文件**
- `LyraHeroComponent.h/cpp` — "粘合层",触发 ASC 初始化
- `LyraPawnExtensionComponent.h/cpp` — "桥接层",热插拔管理
- `LyraCharacter.h/cpp` — Character 本身不持有 ASC通过 PawnExtComp 委托
**核心理解**Character 的生命周期是短暂的死亡→重生。ASC 不能放在 Character 上——否则死亡时数据全丢。PawnExtensionComponent 确保"新 Pawn 连接旧 ASC"。
### 流程 4输入处理 → Ability 激活
```
玩家按左键
→ EnhancedInput 触发 UInputAction
→ ULyraHeroComponent 收到 InputAction
→ 查 ULyraInputConfig 找到对应的 InputTag
→ ASC->AbilityInputTagPressed(InputTag)
遍历 GetActivatableAbilities():
匹配 DynamicSpecSourceTags.HasTagExact(InputTag)
→ InputPressedSpecHandles.AddUnique(Handle)
每帧 Tick
→ ASC->ProcessAbilityInput(DeltaTime, bGamePaused)
1. 检查 InputBlocked Tag如果被阻断清空全部缓冲
2. Held handles: WhileInputActive → TryActivateAbility
3. Pressed handles: OnInputTriggered → 设置 InputPressed → TryActivateAbility
4. 统一批量激活所有待激活 Ability
5. Released handles: 设置 InputPressed=false → InputReleased 事件
6. 清空缓存
```
**关键文件**
- `LyraInputConfig.h` — InputAction ↔ InputTag 映射表
- `LyraAbilitySystemComponent.h/cpp``AbilityInputTagPressed/Released/Held` + `ProcessAbilityInput`
- `LyraHeroComponent.h/cpp` — 输入事件入口
**核心理解**:三层解耦——硬件按键(UInputAction) → 逻辑输入(InputTag) → 技能(AbilitySpec.DynamicSpecSourceTags)。策划可以在任意一层修改映射。
### 流程 5网络复制
```
服务端:
ASC 在 PlayerState 上Replicated
属性变化 → ReplicatedUsing → OnRep 回调
AbilitySpec 通过 ASC 复制
GE 通过 Mixed 模式复制
客户端预测:
ULyraGameplayAbility 默认 NetExecutionPolicy = LocalPredicted
TryActivateAbility → 客户端先执行 → 服务器确认 → ScopedPredictionKey
如果服务器拒绝 → 客户端回滚
属性复制细节:
HealthSet: COND_OwnerOnly仅复制给Owner减少带宽
CombatSet: COND_OwnerOnly
Mixed 复制模式: Minimal 用于纯服务器属性Mixed 用于可修改属性
```
**关键文件**
- `LyraPlayerState.h` — ASC 是 Replicated 组件
- `LyraGameplayAbility.h` — ActivationPolicy/Group 控制预测行为
- `LyraHealthSet.h/cpp` — OnRep_Health 客户端通知
---
## 三、学习路径 — 从哪里开始
### 第 1 天:理解"三个 Actor 各管什么"
只读这些文件,不要跳:
1. `LyraPlayerState.h`60行— ASC 在哪AttributeSet 在哪?什么时候 InitAbilityActorInfo
2. `LyraCharacter.h``GetAbilitySystemComponent` 函数)— Character 如何获取 ASC
3. `LyraPawnExtensionComponent.h`100行— InitializeAbilitySystem / UninitializeAbilitySystem / HandleControllerChanged
**目标回答三个问题**
- 谁的组件拥有 ASCPlayerState
- 谁调用 InitAbilityActorInfoPawnExtensionComponent
- 谁是 Owner谁是 AvatarPlayerState=Owner, Pawn=Avatar
### 第 2 天:理解"技能从哪来"
1. `LyraAbilitySet.h` — 三个 TArrayAbility / GE / AttributeSet
2. `LyraAbilitySet.cpp``GiveToAbilitySystem``TakeFromAbilitySystem`
3. `LyraPawnData.h` — 一个 Pawn 类型用哪些 AbilitySet
4. `LyraPlayerState.cpp``SetPawnData` 函数
**目标**画一张图——AbilitySet → PawnData → PlayerState → ASC → AbilitySpec
### 第 3 天:理解"输入怎么变成技能激活"
1. `LyraInputConfig.h` — InputAction ↔ InputTag 映射结构
2. `LyraAbilitySystemComponent.h``AbilityInputTagPressed` + `ProcessAbilityInput`
3. `LyraGameplayAbility.h` — ActivationPolicy + ActivationGroup 枚举
**目标**画一张图——按键→InputAction→InputTag→DynamicSpecSourceTags→ProcessAbilityInput→TryActivateAbility
### 第 4 天:理解 GameFeature 的"注入"机制
1. `LyraExperienceDefinition.h` — GameFeaturesToEnable + Actions
2. `GameFeatureAction_AddAbilities.h/cpp` — 完整的激活/停用流程
3. `GameFeatureAction_WorldActionBase.h/cpp` — 基类抽象
**目标**:理解 GameFeatureAction 的扩展机制——它本质上是一个Observer模式监听特定ActorClass的创建/销毁,自动注入能力。
### 第 5 天:理解"初始化顺序"
不看代码,先画 Lyra 的 InitState 状态机:
```
Spawned → DataAvailable → DataInitialized → GameplayReady
```
每个组件通过 `IGameFrameworkInitStateInterface` 实现自己对这些状态转换的判断。
然后跟踪 `LyraHeroComponent::CheckDefaultInitialization` 的逻辑。
### 第 6-7 天:综合阅读 LyraExperience 和 GameFeatureAction_AddInputContextMapping
完整跟踪从"服务器选择地图"到"玩家按键激活技能"的全链路。
---
## 四、Lyra 的分支逻辑 — 哪些是主干、哪些是枝叶
### 核心主干(必须理解)
```
LyraExperienceDefinition ← 游戏"模式"的定义
└── LyraExperienceManagerComponent ← 加载/卸载管理器
LyraPlayerState ← 数据持有者ASC + AttributeSet + PawnData
└── LyraAbilitySet ← 技能打包授权
└── LyraPawnData ← Pawn 的配置数据
LyraPawnExtensionComponent ← ASC 桥接层
└── LyraCharacter ← 通过 PawnExtComp 间接获取 ASC
└── LyraHeroComponent ← 玩家专用的粘合层
LyraAbilitySystemComponent ← ASC 扩展(输入缓冲/ActivationGroup/ProcessAbilityInput
└── LyraGameplayAbility ← Ability 基类ActivationPolicy/Group/Cost
GameFeatureAction_AddAbilities ← GameFeature 注入能力的关键 Action
└── GameFeatureAction_WorldActionBase ← 通用 Action 基类
```
### 枝叶(按需学习)
```
LyraHealthSet / LyraCombatSet ← 战斗属性→先学 HealthSet 的 Meta 属性模式
LyraDamageExecution ← 伤害计算→在你需要自定义伤害公式时学
LyraAbilityCost_* ← 消耗系统→在你需要特殊消耗(弹药/能量)时学
GlobalAbilitySystem ← 全局技能分发→在你需要全局Buff时学
LyraGamePhaseSubsystem ← 游戏阶段→在你需要"热身/进行/结束"阶段时学
GameplayTagStack ← Tag栈计数→在你需要弹药/消耗品时学
LyraEquipmentManagerComponent ← 装备系统→在你需要武器/装备切换时学
LyraGameplayCueManager ← Cue加载管理→在你需要大量特效时学
ShooterCore/* ← 射击游戏示范→在你需要参考 Feature 怎么做时学
TopDownArena/* ← 俯视角示范→同上
```
---
## 五、如何迭代增加新功能
### 场景 1新增一个技能最简
只需要编辑 DataAsset不需要写 C++
1. 创建 `GA_MyAbility` 蓝图,父类选 `LyraGameplayAbility`
2. 配置 ActivationPolicy / ActivationGroup
3. 创建 `GE_MyDamage``GE_MyCooldown``GE_MyCost`
4.`AbilitySet_ShooterHero` 中新增一条 `GrantedGameplayAbilities`,配好 InputTag
5. 如果新增了按键,在 `InputData_ShooterGame_AddOns` 中新增 InputAction→InputTag 映射
6. 如果需要阻挡其他技能,在 `TagRelationshipMapping` 中配置
### 场景 2新增一个游戏模式如 FighterGame
1. 创建新的 GameFeature 插件 `FighterCore`(参考 ShooterCore 的 .uplugin 结构)
2. 创建 `AbilitySet_FighterHero`,放入格斗技能
3. 创建 `IMC_FighterGame`InputMappingContext
4. 创建 `InputData_FighterGame`InputConfig
5. 创建 `B_FighterGame.uasset`Experience 定义),配置:
- `GameFeaturesToEnable = ["FighterCore"]`
- `Actions = [GameFeatureAction_AddAbilities, GameFeatureAction_AddInputBinding...]`
6. 如需专用 C++ 组件(连击管理器、输入缓冲),加在 `FighterCore/Source`
7. 选择地图时指定这个 Experience
### 场景 3扩展现有的 GAS 基础设施
1. **新增属性**:新建 AttributeSet 类(如 `URPGStaminaSet`),在 `LyraPlayerState` 构造函数中 `CreateDefaultSubobject`
2. **新增 AbilityTask**:继承 `UAbilityTask`,放在 LyraGame 模块中
3. **新增 GameFeatureAction**:继承 `UGameFeatureAction_WorldActionBase`,重写 `AddToWorld`
4. **新增 AbilityCost 类型**:继承 `ULyraAbilityCost`(如果是 LyraGame 级别)或在 Feature 中自定义
### 场景 4修改初始化顺序
Lyra 的 InitState 是通过 `IGameFrameworkInitStateInterface` 实现的。不要直接改 BeginPlay 顺序,而是:
1. 你的组件实现 `IGameFrameworkInitStateInterface`
2.`CanChangeInitState` 中声明"我要等到哪些条件才能进入下一个状态"
3.`HandleChangeInitState` 中执行状态转换逻辑
4. 其他组件通过 `BindOnActorInitStateChanged` 监听你的状态变化
---
## 六、最重要的 10 个概念速查
| 概念 | 一句话解释 | 在哪里 |
|------|-----------|--------|
| **Experience** | 游戏"模式"的配置清单启用哪些Feature、用哪个Pawn | `LyraExperienceDefinition` |
| **AbilitySet** | 把 Ability + GE + AttributeSet 打包成一个 DataAsset | `LyraAbilitySet` |
| **PawnData** | 一个 Pawn 类型的配置:用哪些 AbilitySet、用什么 TagRelationshipMapping | `LyraPawnData` |
| **PawnExtensionComponent** | ASC 的"插座"Pawn 通过它连接/断开 ASC | `LyraPawnExtensionComponent` |
| **GameFeatureAction** | Observer 模式:监听 Actor 创建→自动注入能力 | `GameFeatureAction_AddAbilities` |
| **InputTag** | EnhancedInput 和 Ability 之间的"中间语言" | `LyraInputConfig` + `DynamicSpecSourceTags` |
| **ActivationGroup** | 技能激活后的"排他性"规则Independent / Replaceable / Blocking | `LyraGameplayAbility` |
| **ProcessAbilityInput** | 帧级输入缓冲,批量激活→避免时序竞争 | `LyraAbilitySystemComponent` |
| **InitState** | 组件的初始化状态机Spawned→DataAvailable→DataInitialized→GameplayReady | `IGameFrameworkInitStateInterface` |
| **VerbMessage** | 松散耦合的事件广播谁受到伤害→UI/音频/统计各管各的) | `FLyraVerbMessage` + `UGameplayMessageSubsystem` |
---
## 七、调试技巧
### 跟踪 Experience 加载过程
`ULyraExperienceManagerComponent::OnExperienceFullLoadCompleted` 打断点。这个函数被调用时,所有 Feature 已加载、所有 Action 已执行。
### 跟踪 ASC 桥接
`ULyraPawnExtensionComponent::InitializeAbilitySystem` 打断点。每次 ASC 被绑定到新 Pawn重生、重连都会触发。
### 跟踪技能授权
`ULyraAbilitySet::GiveToAbilitySystem` 打断点。查看每个 Ability 的 InputTag 和 Level。
### 跟踪输入
`ULyraAbilitySystemComponent::ProcessAbilityInput` 中的三个数组Pressed/Held/Released handles设条件断点看当前帧哪些能力被收集了。
### 验证 GameFeature 是否正确注入
`GameFeatureAction_AddAbilities::AddActorAbilities` 中,检查 `FindOrAddComponentForActor` 返回的 ASC 是不是你期望的那个。
---
## 八、常见误解纠正
1. **"GameFeature 一定会被加载"** → 错。GameFeature 是按需加载的。在 Lyra 中由 Experience 触发加载。没有 Experience 引用它的 Feature 永远不会被加载。
2. **"每个 GameFeature 需要自己的 C++ 模块"** → 不必要。ShooterMaps 就是纯 Content 的 GameFeature没有任何 C++。
3. **"AttributeSet 必须放在 PlayerState 上"** → 不必然。Lyra 的选择是因为 PlayerState 跨地图存活。但也可以放在 Character 上(如 Bot 的 `ALyraCharacterWithAbilities`)。
4. **"InitAbilityActorInfo 只能调用一次"** → 错。Lyra 在重生时会用新的 Pawn 再次调用,关键的 `UninitializeAbilitySystem` 负责清理旧绑定。
5. **"EnhancedInput 的 InputAction 直接绑定到 Ability"** → 错。Lyra 中间插入了 InputTagGameplayTag作为桥梁。这就是为何策划可以独立修改按键和技能映射。
6. **"PawnExtensionComponent 拥有 ASC"** → 错。它的 `AbilitySystemComponent` 成员是 `Transient`,只是一个缓存的指针。真正的所有者是 PlayerState。

View File

@@ -4,6 +4,27 @@
-
- ProjectsUE工程目录。
# 开发计划
以Lyra为基础构建GAS Demo
1. 以Lyra为基础进行初始化
1. 加入GitNexus 引擎代码 以及 项目代码。
2. CC + Obsidian CLI编写Lyra功能模块、代码逻辑文档、代码规范。
3. 使用蓝图编译功能生成c++代码让CC继续理解并且迭代文档。
4. 加入Graphify管理文档。
2. IDE相关MCP加入。
1. Rider
2. VSCode 编译与DebugUE工程
3. 好功能移植
1. GASShooter
2. Auro
3. 询问之后还有其他的开发方向。
4. 其他基建项
1. 自动测试功能探索。
2. Puerts。
1. GitNexus
5. 编写第三人称RPG的代码。
1. 找案例移植?
# 相关技术与容器
- ***[真正帮你省token的11个Claude Code技巧每天在用](https://www.bilibili.com/video/BV1YkQ4BFEr8/?share_source=copy_web&vd_source=fe](https://www.bilibili.com/video/BV1YkQ4BFEr8/?share_source=copy_web&vd_source=fe142e8e12816535feaeabd6f6cdc8e)***