From 273d136c75d2eadb5ceacc364998146078c7027a Mon Sep 17 00:00:00 2001 From: BlueRose <378100977@qq.com> Date: Sun, 31 May 2026 15:35:41 +0800 Subject: [PATCH] vault backup: 2026-05-31 15:35:41 --- .../Puerts/EasyEditorPlugin 架构分析.md | 263 +++++++ .../Puerts/Puerts 架构深度分析.md | 363 +++++++++ .../改造方案 - 将业务 TS 代码嵌入插件.md | 693 ++++++++++++++++++ .../Lyra系统学习指南.md | 656 +++++++++++++++++ .../UnrealEngine Hardness Game Development.md | 21 + 5 files changed, 1996 insertions(+) create mode 100644 07-Other/AI/AI Agent/UnrealEngine/Puerts/EasyEditorPlugin 架构分析.md create mode 100644 07-Other/AI/AI Agent/UnrealEngine/Puerts/Puerts 架构深度分析.md create mode 100644 07-Other/AI/AI Agent/UnrealEngine/Puerts/改造方案 - 将业务 TS 代码嵌入插件.md create mode 100644 07-Other/AI/AI Agent/UnrealEngine/RPGGameplayAbility重构/Lyra系统学习指南.md diff --git a/07-Other/AI/AI Agent/UnrealEngine/Puerts/EasyEditorPlugin 架构分析.md b/07-Other/AI/AI Agent/UnrealEngine/Puerts/EasyEditorPlugin 架构分析.md new file mode 100644 index 0000000..9248334 --- /dev/null +++ b/07-Other/AI/AI Agent/UnrealEngine/Puerts/EasyEditorPlugin 架构分析.md @@ -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 OnJsEnvPreReload; // JS 环境重启前回调 + std::function Eval; // 执行 JS 字符串 + TSparseArray> TsConsoleCommands; // TS 注册的控制台命令 + FSimpleMulticastDelegate OnJsEnvCleanup; // JS 环境清理广播 + +private: + TSharedPtr JsEnv; // ← 私有!外部无法访问 + TSharedPtr SourceFileWatcher; + TUniquePtr 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(热重载) + │ └── 创建 FJsEnv,ModuleLoader 指向 "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( + [this](const FString& InPath) { + if (JsEnv.IsValid()) { + TArray 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( + std::make_shared(TEXT("EasyEditorScripts")), + std::make_shared(), -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` | 重启前执行 | +| `Eval` | `std::function` | 执行任意 JS 字符串 | + +其他插件的集成方式(以 EasyEditor_ImGui 为例): + +```cpp +// 注册清理回调 +FModuleManager::LoadModuleChecked("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 | diff --git a/07-Other/AI/AI Agent/UnrealEngine/Puerts/Puerts 架构深度分析.md b/07-Other/AI/AI Agent/UnrealEngine/Puerts/Puerts 架构深度分析.md new file mode 100644 index 0000000..274c4a6 --- /dev/null +++ b/07-Other/AI/AI Agent/UnrealEngine/Puerts/Puerts 架构深度分析.md @@ -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(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,使用 PESAPI(Portable 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`,回调 `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(42, TEXT("hello")); // 无返回值 +int32 Result = MyFunc.Func(42, TEXT("hello")); // 有返回值 +int32 Val = MyObj.Get("someProperty"); // 读取属性 +MyObj.Set("someProperty", 42); // 设置属性 +``` + +自动使用 `puerts::v8_impl::Converter` 进行 C++ ↔ V8 类型转换。线程安全(`THREAD_SAFE` 模式下使用 `v8::Locker`)。 + +### 方式 2:UFUNCTION / 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 Context, v8::Local 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` | 聚合所有声明(通过 `/// `) | + +`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 加载器 | diff --git a/07-Other/AI/AI Agent/UnrealEngine/Puerts/改造方案 - 将业务 TS 代码嵌入插件.md b/07-Other/AI/AI Agent/UnrealEngine/Puerts/改造方案 - 将业务 TS 代码嵌入插件.md new file mode 100644 index 0000000..be5c64f --- /dev/null +++ b/07-Other/AI/AI Agent/UnrealEngine/Puerts/改造方案 - 将业务 TS 代码嵌入插件.md @@ -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( + std::make_shared(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& Content) override + { + return DefaultLoader.Load(Path, Content); + } + + virtual FString& GetScriptRoot() override + { + return ScriptRoot; + } + + static FString DeterminePluginContentDir() + { + // 获取当前插件自身的 Content 目录 + // 方案 A:通过 IPluginManager 查询 + TSharedPtr 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 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( + [this](const FString& InPath) { /* 同上 */ }); + + // 使用自定义 Loader,从插件 Content/BusinessScripts/ 加载 + JsEnv = MakeShared( + std::make_shared(TEXT("BusinessScripts")), + std::make_shared(), -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 JsEnv; // ← 外部无法访问 +}; +``` + +外部 C++ 只能通过 `Eval` 执行 JS 字符串,无类型安全、无返回值。 + +#### 改造:公开 JsEnv 访问和 FJsObject 获取 + +在 `EasyEditorPlugin.h` 中新增: + +```cpp +class FEasyEditorPluginModule : public IModuleInterface +{ +public: + // ===== 新增 API ===== + + /** + * 从 JS 全局作用域获取一个函数/对象,返回 FJsObject 用于类型安全调用 + * 示例: + * auto& Mod = FModuleManager::LoadModuleChecked("EasyEditorPlugin"); + * FJsObject MyFunc = Mod.GetJsExport("myBusinessFunction"); + * int32 Result = MyFunc.Func(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 GetJsEnv() const { return JsEnv; } + + // ===== 原有 API ===== + std::function OnJsEnvPreReload; + std::function Eval; + FSimpleMulticastDelegate OnJsEnvCleanup; + TSparseArray> 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(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 Val; + if (MaybeValue.ToLocal(&Val) && Val->IsObject()) + { + return FJsObject(Context, Val.As()); + } + + 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 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("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: 回调注册 | 低 | ❌ | 不需要 | ⭐(反向模式) | + +**最佳实践:方案 C(PUERTS_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 GetJsEnv() const { return JsEnv; } + +private: + TSharedPtr 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 Plugin = IPluginManager::Get() + .FindPlugin(TEXT("MyBusinessPlugin")); + FString PluginContent = Plugin.IsValid() + ? Plugin->GetContentDir() + : FPaths::ProjectPluginsDir() / TEXT("MyBusinessPlugin/Content"); + + // 创建自定义 ModuleLoader,从插件 Content/BusinessScripts/ 加载 + auto Loader = MakeShared( + PluginContent / TEXT("BusinessScripts")); + + JsEnv = MakeShared( + Loader, + MakeShared(), + -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 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(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( + [this](const FString& InPath) { + if (JsEnv.IsValid()) { + TArray 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` 条件编译 diff --git a/07-Other/AI/AI Agent/UnrealEngine/RPGGameplayAbility重构/Lyra系统学习指南.md b/07-Other/AI/AI Agent/UnrealEngine/RPGGameplayAbility重构/Lyra系统学习指南.md new file mode 100644 index 0000000..4d037aa --- /dev/null +++ b/07-Other/AI/AI Agent/UnrealEngine/RPGGameplayAbility重构/Lyra系统学习指南.md @@ -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 │ +│ GameModule(C++ 编译单元,启动时强制加载) │ +│ (游戏框架: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.dll(GameModule,不可卸载) │ +│ → 所有 UCLASS 注册到反射系统 │ +│ → ULyraGameEngineSubsystem::Initialize() │ +│ → UGameFeaturesSubsystem 初始化 │ +└───────────────────────────────────────────────────────────┘ + ↓ +┌─ 阶段1:进入地图 ─────────────────────────────────────────┐ +│ World 创建 │ +│ → GameState 创建 → ULyraExperienceManagerComponent 创建 │ +│ → GameMode 创建 → 调用 ChoosePlayerStart 等 │ +│ │ +│ 重点:此时还没有加载任何 GameFeature。 │ +│ 只有 LyraGame 的 C++ 类在内存中。 │ +└───────────────────────────────────────────────────────────┘ + ↓ +┌─ 阶段2:Experience 加载(最关键!)────────────────────────┐ +│ 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` | 先学 HealthSet(4属性),再学 CombatSet(2属性) | +| **伤害怎么算** | `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 具体例子**: + +``` +LyraGame(GameModule — 编译时加载) + │ 定义基础设施:ULyraAbilitySystemComponent、ULyraGameplayAbility、 + │ ULyraHealthSet、ULyraAbilitySet、UGameFeatureAction_AddAbilities... + │ 这些东西永远在内存中,不可卸载。 + │ + ├── ShooterCore(GameFeature Plugin — 运行时加载) + │ │ 没有 C++ 基类定义,只有: + │ │ - AbilitySet_ShooterHero(DataAsset:有哪些技能) + │ │ - IMC_ShooterGame(InputMappingContext:按键绑定) + │ │ - InputData_ShooterGame_AddOns(InputConfig:按键→Tag映射) + │ │ - UAimAssistInputModifier(少量专用 C++) + │ │ - 武器蓝图、地图、Experience 定义 + │ │ + │ │ 加载时:通过 UGameFeatureAction_AddAbilities 把 AbilitySet + │ │ 里的技能"注入"到 PlayerState 的 ASC 上。 + │ │ 卸载时:全部回收。 + │ + └── TopDownArena(GameFeature 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.Actions(UGameFeatureAction 列表) + → 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`。 + +### 流程 2:PlayerState 初始化 → 技能授权 + +``` +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` + +### 流程 3:Pawn 生成 → 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 + +**目标回答三个问题**: +- 谁的组件拥有 ASC?(PlayerState) +- 谁调用 InitAbilityActorInfo?(PawnExtensionComponent) +- 谁是 Owner,谁是 Avatar?(PlayerState=Owner, Pawn=Avatar) + +### 第 2 天:理解"技能从哪来" + +1. `LyraAbilitySet.h` — 三个 TArray(Ability / 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 中间插入了 InputTag(GameplayTag)作为桥梁。这就是为何策划可以独立修改按键和技能映射。 + +6. **"PawnExtensionComponent 拥有 ASC"** → 错。它的 `AbilitySystemComponent` 成员是 `Transient`,只是一个缓存的指针。真正的所有者是 PlayerState。 diff --git a/07-Other/AI/AI Agent/UnrealEngine/UnrealEngine Hardness Game Development.md b/07-Other/AI/AI Agent/UnrealEngine/UnrealEngine Hardness Game Development.md index cfcd898..7ab595e 100644 --- a/07-Other/AI/AI Agent/UnrealEngine/UnrealEngine Hardness Game Development.md +++ b/07-Other/AI/AI Agent/UnrealEngine/UnrealEngine Hardness Game Development.md @@ -4,6 +4,27 @@ - - Projects:UE工程目录。 +# 开发计划 +以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)***