1. 项目概述为什么要在TypeScript里调用Unreal C如果你正在用Unreal Engine做游戏尤其是那种逻辑复杂、需要快速迭代的项目大概率会遇到一个经典难题用蓝图还是用C蓝图可视化上手快但项目一大连线就乱成一团麻版本管理也头疼C性能强结构清晰但编译慢热更新困难对策划和TA同学也不够友好。有没有一种方法能像写脚本一样灵活地驱动Unreal又能享受到现代语言和工具链的便利这就是Puerts读作“普洱TS”要解决的问题。简单说它是一座桥一座连接Unreal C世界和TypeScript/JavaScript世界的桥。它让你能用TypeScript来写游戏逻辑直接调用你已有的或者新写的C类和方法享受TS的快速开发、即时热重载、以及强大的IDE支持同时底层依然是高性能的C在运行。我最初接触Puerts是因为一个MMO项目的技能系统。用C写每次改个伤害公式或者效果触发条件都要重新编译、打包、部署到测试服一天也验证不了几个想法。后来团队引入了Puerts我把技能逻辑全部用TypeScript重写策划在编辑器里改个数值保存一下游戏里立刻生效迭代效率提升了不止一个量级。更重要的是TypeScript的强类型和模块化让技能配置表、效果链这些复杂逻辑变得前所未有的清晰。所以这篇指南不是简单的API罗列而是基于我踩过无数坑的实战经验带你从零开始打通从C类暴露到TypeScript调用的完整链路。无论你是想为现有项目引入脚本系统提升开发效率还是在新项目中尝试更现代化的技术栈相信这些内容都能给你提供直接的参考。2. 环境搭建与项目初始化在开始写代码之前得先把“舞台”搭好。Puerts的集成方式比较灵活但为了稳定和可维护性我强烈推荐使用Unreal的插件系统并将TypeScript部分作为独立的模块来管理。2.1 安装Puerts插件首先你需要一个C版本的Unreal项目。纯蓝图项目是无法直接集成Puerts的。获取Puerts最稳妥的方式是从GitHub仓库克隆。打开命令行进入你的Unreal项目根目录下的Plugins文件夹如果没有就创建一个。cd YourUnrealProject/Plugins git clone https://github.com/Tencent/puerts.git这样插件源码就在你的项目控制之下方便后续根据项目需求进行定制化修改。不推荐直接下载Release包因为作为深度集成你可能需要查看或调试其内部逻辑。生成项目文件克隆完成后回到Unreal项目根目录右键点击.uproject文件选择“Generate Visual Studio project files”。这一步至关重要它会让Unreal构建系统识别到新加入的Puerts插件。编译与启用用Visual Studio或其他IDE打开生成的项目解决方案编译整个项目。编译成功后启动Unreal编辑器。在编辑器的“编辑” - “插件”窗口中搜索“Puerts”你应该能看到它。确保其“已启用”复选框是勾选状态。注意第一次编译可能会比较久因为Puerts本身包含一些第三方库如V8 JavaScript引擎需要编译。如果编译失败请检查你的Visual Studio版本是否安装了“使用C的桌面开发”工作负载以及Windows SDK版本是否匹配。2.2 配置TypeScript开发环境Puerts插件只提供了运行时和绑定层我们还需要一个舒适的TypeScript编写环境。创建Scripts目录在项目根目录下与Content、Source同级创建一个新文件夹例如TypeScript。所有TS源码都将放在这里与Unreal的C、资产目录分离结构清晰。初始化Node.js项目在TypeScript文件夹内打开终端执行npm init -y这会生成一个package.json文件。接着安装TypeScript编译器和Puerts的类型定义文件npm install typescript types/puerts -Dtypes/puerts这个包包含了所有Unreal引擎内置类以及Puerts扩展函数的TypeScript声明是获得代码提示和类型检查的关键。配置tsconfig.json在TypeScript目录下创建tsconfig.json这是TypeScript项目的核心配置文件。{ compilerOptions: { target: es2016, module: commonjs, lib: [es2016], outDir: ../Content/JavaScript, rootDir: ., strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, declaration: true, sourceMap: true }, include: [ src/**/* ], exclude: [ node_modules ] }这里有几个关键点outDir: ../Content/JavaScript指定编译输出的JS文件到Unreal的Content/JavaScript目录。Puerts默认会从这个目录加载脚本。rootDir: .确保源文件结构在输出时得以保留。declaration: true生成.d.ts类型声明文件有利于模块间的类型引用。sourceMap: true生成源码映射这样在Unreal中调试TypeScript时错误信息可以定位到原始的TS文件行号而不是编译后的JS极大提升调试效率。2.3 第一个Hello World验证环境配好了我们来跑一个最简单的例子验证整个链路是否通畅。编写TypeScript在TypeScript/src目录下创建HelloWorld.ts。import * as UE from ue console.log(Hello from TypeScript!); // 尝试调用一个简单的Unreal静态函数 const world UE.GWorld; if (world) { console.log(Current World: ${world.GetName()}); }编译TypeScript在TypeScript目录下执行npx tsc。如果配置正确你会在Content/JavaScript/src下看到生成的HelloWorld.js和HelloWorld.d.ts文件。在Unreal中加载启动你的Unreal编辑器项目。在关卡蓝图中或者创建一个新的Actor蓝图。在事件图表中搜索“Execute Js”节点这是Puerts提供的一个蓝图节点。将其“Js File”参数设置为src/HelloWorld.js相对路径从Content/JavaScript开始。将节点连接到BeginPlay事件。运行游戏点击Play。如果一切正常你将在Unreal编辑器的“输出日志”窗口中看到“Hello from TypeScript!”以及当前世界的名称。如果这一步成功了恭喜你桥梁已经架通接下来我们要学习如何让这座桥承载更重要的“货物”——你自己的C类。3. 核心机制如何将C类暴露给TypeScriptPuerts实现C与TS交互的核心是一套基于反射和代码生成的绑定机制。你不需要手动写复杂的胶水代码而是通过一些声明和标记让工具帮你生成一切。主要有两种方式基于宏的声明式和基于标注的自动绑定。我这里主要讲更灵活、更推荐的方式——使用UCLASS宏配合BlueprintType和Blueprintable并结合Puerts的扩展模块。3.1 创建可被脚本访问的C类假设我们有一个最基础的玩家角色类AMyPlayerCharacter我们想在其中添加一个可以被TypeScript调用和重写的方法。首先在C头文件如MyPlayerCharacter.h中定义这个类// MyPlayerCharacter.h #pragma once #include CoreMinimal.h #include GameFramework/Character.h #include MyPlayerCharacter.generated.h // 必须包含生成的头文件 UCLASS(BlueprintType, Blueprintable) // 关键BlueprintType允许在TS中作为类型使用Blueprintable允许蓝图和TS继承 class MYPROJECT_API AMyPlayerCharacter : public ACharacter { GENERATED_BODY() public: AMyPlayerCharacter(); // 声明一个可被蓝图和TypeScript调用的函数 UFUNCTION(BlueprintCallable, Category MyPlayer) virtual void TakeDamage(float DamageAmount); // 声明一个可被蓝图和TypeScript读取/设置的属性 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category MyPlayer) float Health; // 一个纯C函数默认对TS不可见。我们稍后会暴露它。 void InternalCalculateDamage(float OutDamage); };关键点在于UCLASS和UFUNCTION上的标记BlueprintType和Blueprintable这是Puerts识别并生成TS类型定义的基础。没有这个类无法在TS侧被实例化或继承。BlueprintCallable使函数能够被TS调用。BlueprintReadWrite使属性能够被TS读取和修改。3.2 使用Puerts的扩展模块进行深度绑定上面的方法可以暴露基本的函数和属性但对于重载虚函数、暴露纯C函数、或者绑定非UObject类如普通的C类或结构体就需要用到Puerts的扩展模块。这是更强大和常用的方式。创建扩展模块文件在你的游戏模块如Source/MyProject/下创建一个新文件例如PuertsExtensions.cpp。这个文件将集中管理所有需要额外绑定的逻辑。编写绑定代码在PuertsExtensions.cpp中使用Puerts提供的宏来声明绑定。// PuertsExtensions.cpp #include PuertsModule.h #include MyPlayerCharacter.h #include MyNonUObjectClass.h // 假设我们有一个非UObject的类 // 使用宏定义一个扩展模块 PUERTS_MODULE(MyProjectExtensions, Module) { // 绑定一个全局函数 Module-Function(LogToGame, MakeFunctionvoid(std::string)( [](std::string Message) { UE_LOG(LogTemp, Log, TEXT(%s), UTF8_TO_TCHAR(Message.c_str())); } )); // 绑定一个非UObject的C类 using NonUObjectClass MyNonUObjectClass; Module-ClassNonUObjectClass(MyNonUObjectClass) .Constructor() // 默认构造函数 .Property(SomeValue, NonUObjectClass::GetSomeValue, NonUObjectClass::SetSomeValue) .Function(DoWork, MakeFunction(NonUObjectClass::DoWork)) .Register(); // 对已有的UObject类进行扩展绑定例如暴露那个纯C函数 using AMyPlayerCharacterClass AMyPlayerCharacter; Module-ClassExtensionAMyPlayerCharacterClass(MyPlayerCharacter) .Method(InternalCalculateDamage, SelectFunction(void(AMyPlayerCharacterClass::*)(float), AMyPlayerCharacterClass::InternalCalculateDamage)) .Register(); }这段代码做了三件事绑定了一个全局工具函数LogToGame方便TS打印日志到Unreal输出窗口。绑定了一个普通的C类MyNonUObjectClass使其可以在TS中被new出来并调用方法。对已有的AMyPlayerCharacter类进行了扩展暴露了其内部的InternalCalculateDamage方法。注册扩展模块你需要在游戏模块启动时告诉Puerts加载这个扩展。在你的主游戏模块实现文件如MyProject.cpp的StartupModule函数中添加#include IPuertsModule.h #include Modules/ModuleManager.h void FMyProjectModule::StartupModule() { // ... 其他初始化代码 IPuertsModule::Get().RegisterExtensionModule(MyProjectExtensions); }3.3 生成TypeScript定义文件C侧的工作完成后我们需要让TypeScript知道这些新类型。Puerts提供了一个命令行工具来生成定义文件。编译项目确保你的C项目已经成功编译这样反射信息才可用。运行生成命令在项目根目录下执行路径根据你的Puerts安装位置调整./Plugins/puerts/bin/win-x64/CodeGen.exe --moduleYourProjectName --output./TypeScript/src/generated这个命令会扫描你项目中所有标记了BlueprintType的UCLASS以及你注册的扩展模块并在指定的output目录下生成对应的.d.ts文件。整合到TS项目将生成的generated文件夹纳入你的tsconfig.json的include路径中。现在在你的TypeScript代码里你就可以获得完整的代码提示了。import * as UE from ue import { AMyPlayerCharacter } from ./generated/MyPlayerCharacter // 导入生成的定义 let player: AMyPlayerCharacter ...; // 从世界获取或转换 player.TakeDamage(10.0); // 调用BlueprintCallable函数 console.log(player.Health); // 访问BlueprintReadWrite属性 // 调用通过扩展模块暴露的函数 const damage 10.0; player.InternalCalculateDamage(damage); // 现在可以调用了实操心得生成定义文件这一步很容易被忽略但至关重要。我建议将这条生成命令写入package.json的scripts中例如gen-defs: CodeGen.exe --moduleMyProject --output./src/generated并在每次修改C类暴露的接口后运行一次。这样可以保证TS端的类型安全避免运行时出现“undefined is not a function”这类错误。4. TypeScript侧的调用、继承与交互当C类被成功暴露后TypeScript侧的操作就非常直观和强大了。你可以像使用普通TypeScript类一样使用它们。4.1 获取和操作Unreal对象在TS中你通常不会直接new一个Unreal Actor而是从游戏世界中获取已有的实例或者通过Unreal的接口来生成。import * as UE from ue import { AMyPlayerCharacter } from ./generated/MyPlayerCharacter; class MyGameLogic { private playerCharacter: AMyPlayerCharacter | null null; onGameStart(): void { // 方法1通过游戏世界获取玩家控制器再获取Pawn const world UE.GWorld; const playerController world.GetFirstPlayerController(); if (playerController) { const pawn playerController.GetPawn(); // 使用“as”进行类型转换需要先导入生成的定义 this.playerCharacter pawn as AMyPlayerCharacter; if (this.playerCharacter) { this.playerCharacter.Health 100.0; this.playerCharacter.TakeDamage(20.0); console.log(Player health: ${this.playerCharacter.Health}); } } // 方法2使用Unreal的SpawnActor接口需提前在C中暴露或通过全局函数 // 假设我们有一个全局的SpawnHelper函数通过扩展模块暴露 const spawnLocation new UE.Vector(0, 0, 300); const spawnRotation new UE.Rotator(0, 0, 0); const actorClass UE.Class.Load(/Game/Blueprints/BP_MyEnemy.BP_MyEnemy_C); // 加载蓝图类 const spawnedActor UE.GWorld.SpawnActor(actorClass, spawnLocation, spawnRotation); // 对spawnedActor进行操作... } }4.2 继承C类并重写虚函数这是Puerts最强大的特性之一。你可以在TypeScript中创建一个类继承自某个Unreal C类并重写其虚函数。这对于实现差异化的游戏逻辑如不同怪物的AI、不同技能的效果特别有用。首先确保C基类的函数被声明为virtual且是BlueprintCallable或通过扩展暴露的。import * as UE from ue import { AMyBaseEnemy } from ./generated/MyBaseEnemy; // 假设有一个基类敌人 // 在TypeScript中继承Unreal C类 class MyBossEnemy extends AMyBaseEnemy { private specialAbilityCooldown: number 0; // 重写基类的虚函数 TakeDamage(DamageAmount: number): void { // 先调用父类逻辑 super.TakeDamage(DamageAmount); // 添加Boss特有的逻辑伤害减免 const actualDamage DamageAmount * 0.7; // 减免30% this.Health - actualDamage; console.log(Boss受到 ${actualDamage} 点伤害剩余生命 ${this.Health}); // 触发特殊技能 if (this.Health this.MaxHealth * 0.5 this.specialAbilityCooldown 0) { this.castSpecialAbility(); this.specialAbilityCooldown 10.0; // 10秒冷却 } } // 定义一个TS特有的方法 private castSpecialAbility(): void { console.log(Boss释放了全屏大招); // 这里可以调用C暴露的粒子、音效接口等 const world this.GetWorld(); // ... 生成特效的逻辑 } // 重写Tick函数如果基类暴露了Tick Tick(DeltaTime: number): void { super.Tick(DeltaTime); if (this.specialAbilityCooldown 0) { this.specialAbilityCooldown - DeltaTime; } } } // 如何使用这个TS类你需要一个“入口点”将其注册或实例化。 // 通常你会在游戏初始化时用这个TS类替换掉原有的C类实例。 // 这可以通过Puerts的“JsEnv.Override”功能或者在C中SpawnActor时指定一个特殊的“脚本类名”来实现。注意事项继承和重写需要Puerts运行时的支持。你需要确保这个TypeScript类在游戏启动时被加载并且与对应的Unreal对象关联起来。一种常见模式是在C中定义一个空的Actor基类然后在TypeScript中实现其所有行为逻辑。C只负责物理、渲染等底层交互真正的游戏逻辑全部在TS中。4.3 事件绑定与回调游戏开发离不开事件驱动。Puerts允许你在TypeScript中方便地绑定Unreal的事件Delegates。假设在C中有一个委托DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth); UCLASS() class AMyCharacter : public ACharacter { GENERATED_BODY() public: UPROPERTY(BlueprintAssignable) FOnHealthChanged OnHealthChanged; };在TypeScript中可以这样绑定import * as UE from ue import { AMyCharacter } from ./generated/MyCharacter; let character: AMyCharacter ...; // 绑定事件 character.OnHealthChanged.Add((newHealth: number) { console.log(生命值变为: ${newHealth}); // 更新UI血条等操作 }); // 未来当C侧触发 character.OnHealthChanged.Broadcast(50.0); 时这个TS回调就会被执行。5. 调试、性能与最佳实践将逻辑移到脚本层调试和性能就成了需要重点关注的问题。5.1 TypeScript调试技巧使用Source Map如前文tsconfig.json配置中开启sourceMap: true。这样当在Puerts环境中报错时错误堆栈会指向原始的.ts文件行号而不是编译后的.js文件。Console.log 与 UE_LOG在开发阶段console.log是你的好朋友。它会输出到Puerts的控制台和Unreal的OutputLog。对于复杂对象可以使用JSON.stringify进行序列化查看。对于需要更正式、分级的日志可以通过我们之前绑定的LogToGame函数或者直接调用UE_LOG在C端定义的函数。利用IDE调试更高级的调试需要配置调试器。Puerts支持V8 Inspector协议。你可以通过启动参数或代码启动调试服务器然后在Chrome DevTools或VS Code中附加调试器进行断点、单步执行、变量查看等操作。这需要一些额外的配置但对于排查复杂逻辑问题非常有效。5.2 性能优化要点TypeScript虽然方便但毕竟是解释执行性能无法与原生C相比。遵循一些原则可以避免性能瓶颈热点逻辑留在C每帧执行的密集计算如物理检测、大量数学运算、粒子更新务必放在C端。TS适合做决策逻辑、配置解析、UI控制等。减少TS与C的边界调用每一次从TS调用C函数或从C回调TS函数都有一定的开销。避免在循环尤其是每帧的Tick中进行大量的跨边界调用。例如不要在一帧内通过TS循环调用C设置100个物体的位置而应该一次传递所有数据给C函数处理。对象生命周期管理Unreal的UObject有垃圾回收GC但Puerts管理的JavaScript对象也有自己的GC。注意循环引用可能导致的内存泄漏。对于明确需要长期持有的大型对象考虑在TS端使用弱引用WeakRef或在C端使用强引用。使用对象池频繁在TS中创建和销毁复杂的对象尤其是封装了C对象的代理会产生开销。对于像子弹、特效触发器这类频繁生成的对象实现一个简单的对象池。5.3 项目结构最佳实践一个中型以上项目如果没有良好的结构TS代码很快就会变得难以维护。TypeScript/ ├── src/ │ ├── generated/ # Puerts自动生成的类型定义不手动修改 │ ├── core/ # 核心框架、管理器GameMode、UIManager、EventSystem │ ├── character/ # 角色相关逻辑Player, NPC, AI │ ├── ability/ # 技能系统 │ ├── item/ # 物品、装备系统 │ ├── ui/ # UI界面逻辑 │ └── utils/ # 工具函数、通用类 ├── package.json ├── tsconfig.json └── webpack.config.js # 可选用于打包和代码分割模块化使用ES Module进行清晰的导入导出。一个文件只做一个事情。依赖注入考虑使用一个轻量级的IoC容器来管理各模块之间的依赖避免全局单例满天飞提高可测试性。配置数据与逻辑分离将数值、公式、ID等配置放在JSON文件中由TS逻辑读取。这样策划可以独立修改配置而无需重新编译C或TS结合热重载。6. 常见问题与排查实录在实际开发中你肯定会遇到各种问题。这里记录了几个最典型的情况和解决方法。6.1 绑定失败与“undefined is not a function”这是最常见的问题意味着TypeScript运行时找不到你试图调用的C函数或属性。检查步骤C类是否标记正确确认头文件中的UCLASS(BlueprintType, Blueprintable)和UFUNCTION(BlueprintCallable)是否存在且拼写正确。是否重新生成了TypeScript定义修改C暴露的接口后必须重新运行CodeGen.exe命令并确保生成的.d.ts文件被更新。模块是否注册如果是通过扩展模块PUERTS_MODULE绑定的非UFUNCTION函数检查模块名是否在StartupModule中正确注册。脚本是否加载确认你的.js文件被正确加载通过Execute Js节点或JsEnv.Load。可以在脚本开头加一句console.log来验证。类型转换是否正确在TS中从一个通用的UE.Actor转换到你的AMyPlayerCharacter需要使用as关键字并且确保运行时该对象确实是你期望的类型。6.2 热重载不生效热重载是提升效率的利器但有时会失灵。可能原因文件监视未开启Puerts的文件监视器可能没有正确启动。检查编辑器的输出日志看是否有关于文件监听初始化的信息。输出目录不对tsc编译输出的.js文件目录必须与Puerts配置中或Execute Js节点指定的加载路径一致。默认是Content/JavaScript。脚本缓存尝试在编辑器中手动执行一次“Reload All JavaScript”如果有相关命令或按钮或者重启编辑器。状态丢失热重载会重新执行脚本文件但不会自动恢复之前脚本中全局变量的状态。对于需要持久化的数据如玩家血量应该存储在C端或Unreal的Actor属性上而不是TS的全局变量中。6.3 内存泄漏排查长时间运行游戏后如果内存持续增长可能是内存泄漏。排查方向循环引用检查TS对象和C对象通过Puerts包装之间是否存在循环引用。例如一个TS管理器持有了一个Actor引用而这个Actor的某个事件又回调了TS管理器。使用弱引用WeakRef打破循环。未清理的监听器在TS中绑定了C的事件委托但在对象销毁如Actor被Destroy时没有移除监听器。这会导致C对象无法被释放因为委托还持有对TS函数的引用。确保在EndPlay或析构逻辑中调用RemoveAll或对应的移除函数。使用Puerts提供的性能分析工具Puerts可能内置或社区提供了一些内存快照工具可以定期检查JS堆内存中的对象数量和大小的变化定位异常增长点。6.4 与现有蓝图/C系统的协作项目不可能是完全绿色的如何与已有的蓝图或C代码协作调用蓝图函数如果蓝图函数是基于BlueprintCallable的C函数实现的那么TS可以直接调用。如果是纯蓝图函数在蓝图中实现则需要通过其他方式暴露例如在C中写一个包装函数调用蓝图的CallFunctionByNameWithArguments或者将重要的蓝图功能重构到C基类中。被蓝图调用TS函数如果想被蓝图调用需要在C端提供一个桥梁。例如在C类中定义一个UFUNCTION(BlueprintCallable)函数在这个函数内部调用一个全局的TS入口函数通过Puerts执行特定脚本。数据传递复杂的数据结构如结构体数组在TS和C之间传递时可能需要序列化/反序列化。简单的数据可以直接传递复杂的可以考虑使用JSON字符串作为中介或者使用Puerts对TArray、TMap等容器的绑定支持如果实现了的话。从我的经验来看成功引入Puerts的关键在于“循序渐进”。不要试图一下子把整个项目的逻辑都搬过来。从一个小的、相对独立的子系统开始比如任务系统、对话系统验证技术流程积累团队经验再逐步推广到更核心的模块。这能最大程度地控制风险并让团队平滑地过渡到新的开发模式中。