3.2 C++ 游戏编程基础 本节摘要:引擎环境下的 C++ 不是裸写语言,而是在 UObject 体系里"带着镣铐跳舞":反射宏决定谁能被引擎看见,垃圾回收决定对象活多久,游戏框架决定类怎么分工。本节讲清这套环境规则,并用一个角色类走通从声明到输入绑定的全流程。 会 C++ 和会引擎 C++ 是两回事 从通用 C++ 转进引擎开发,最容易踩的三块石头都不是语法:一是new和delete满天飞,与引擎的垃圾回收体系打架;二是把标准库智能指针、标准容器与引擎智能指针、引擎容器混着用,序列化与反射全部失效;三是不知道该继承哪个框架类,写个管理器直接全局单例,网络同步一来全部返工。这三块的共同根源是同一个——引擎 C++ 的第一身份是 UObject 体系的公民,语言只是公民的母语。
本节摘要:引擎环境下的 C++ 不是裸写语言,而是在 UObject 体系里"带着镣铐跳舞":反射宏决定谁能被引擎看见,垃圾回收决定对象活多久,游戏框架决定类怎么分工。本节讲清这套环境规则,并用一个角色类走通从声明到输入绑定的全流程。
从通用 C++ 转进引擎开发,最容易踩的三块石头都不是语法:一是new和delete满天飞,与引擎的垃圾回收体系打架;二是把标准库智能指针、标准容器与引擎智能指针、引擎容器混着用,序列化与反射全部失效;三是不知道该继承哪个框架类,写个管理器直接全局单例,网络同步一来全部返工。这三块的共同根源是同一个——引擎 C++ 的第一身份是 UObject 体系的公民,语言只是公民的母语。
本节承接 2.1 的反射与回收概念(那里讲了"为什么",本节讲"怎么写"),向下为 3.3 互操作提供"C++ 侧"的视角,也为第七章技能框架、第八章网络同步的代码案例提供语法基础。
引擎的玩法框架把一场对局拆成固定角色,写 C++ 前先背下这张分工表,选对基类能省掉一半架构纠结。
| 框架类 | 职责 | 典型子类化场景 |
|---|---|---|
| GameMode | 对局规则:谁能加入、怎么判胜负 | 只在服务器存在,改规则时继承 |
| GameState | 对局共享状态:比分、阶段 | 全员可见的数据放这里 |
| PlayerController | 玩家意志:输入、视角、与 UI 对话 | 处理输入与界面交互 |
| Pawn / Character | 场上行动的躯体 | Character 自带移动与胶囊碰撞 |
| PlayerState | 单个玩家的公开档案:昵称、得分 | 多人局展示玩家信息 |
| HUD / Widget | 界面表现 | 接 Controller 的数据画界面 |
分工的原则是"数据归数据、意志归意志、躯体归躯体"。血量放 Character 还是 PlayerState,判据就是"死后重生还保留吗、别人可见吗"——可见且跨生命数据进 PlayerState,躯体自身数据进 Character。多人项目的多数返工,都源于一开始把这些数据放错了类。
引擎 C++ 与标准 C++ 最大的外观差异是满屏宏。这些宏不是装饰,是给反射系统登记的申报单:UCLASS 声明类、UPROPERTY 声明属性、UFUNCTION 声明函数;括号里的说明符决定可见性与行为——EditAnywhere 编辑器可改、BlueprintReadWrite 蓝图可读写、Replicated 网络同步(第八章)、Transient 不序列化。申报过的成员进入反射系统,享受编辑器展示、蓝图访问、序列化保存、垃圾回收跟踪的完整服务;没申报的,就是引擎看不见的私有角落。
垃圾回收与申报的联动规则在 2.1 讲过,落到写代码就是一条铁律:持有 UObject 指针的成员变量必须 UPROPERTY。没申报的裸指针在回收器眼里不存在,它指向的对象随时可能被收走。容器同理:TArray 存对象指针也要套 UPROPERTY。引擎另提供三种非 UObject 智能指针处理普通 C++ 对象——共享指针(强引用计数)、弱指针(不阻止销毁的观察者)、共享引用(不可为空的强引用),它们的场合是"这个类不是 UObject 但也需要引用管理",别与 UObject 指针混为一谈。
生命周期函数记四个就够:构造函数(创建默认子对象,只做组件拼装)、BeginPlay(游戏开始,逻辑初始化)、Tick(每帧执行,能用事件替代就别用)、EndPlay(收尾清理)。这套顺序在 2.1 的一帧接力图里能找到位置。
背景:第三人称项目需要一个自定义角色——C++ 管移动参数与输入接口,蓝图管模型外观与动画,这是双轨分工的最典型样本。
操作:类向导创建继承 Character 的类 AMyCharacter。头文件里申报三样东西:弹簧臂组件、摄像机组件、移动速度属性。源文件构造函数里创建两个组件并挂接层级;BeginPlay 里从增强输入绑定移动与跳跃动作;移动处理函数里,取输入的二维向量,按摄像机朝向折算成世界方向的移动请求交给内置移动组件。
UCLASS() class MYPROJECT_API AMyCharacter : public ACharacter { GENERATED_BODY() public: AMyCharacter(); // 弹簧臂与摄像机:申报后编辑器可调参数 UPROPERTY(VisibleAnywhere, Category = "Camera") class USpringArmComponent* SpringArm; UPROPERTY(VisibleAnywhere, Category = "Camera") class UCameraComponent* Camera; // 移动速度:蓝图可读,折算进移动请求 UPROPERTY(EditAnywhere, Category = "Movement") float MaxWalkSpeedCmp = 600.f; protected: virtual void BeginPlay() override; // 输入回调:由增强输入系统在按键时触发 void Move(const struct FInputActionValue& Value); void DoJump(const struct FInputActionValue& Value); }; AMyCharacter::AMyCharacter() { SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm")); SpringArm->SetupAttachment(RootComponent); // 挂在胶囊体上 SpringArm->TargetArmLength = 300.f; // 拉远观察距离 SpringArm->bUsePawnControlRotation = true; // 跟随视角旋转 Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera")); Camera->SetupAttachment(SpringArm); // 相机挂在弹簧臂末端 } void AMyCharacter::Move(const FInputActionValue& Value) { const FVector2D Axis = Value.Get<FVector2D>(); // 以控制器朝向为基准折算前后左右 —— 角色朝向与镜头朝向解耦 const FRotator YawRot(0.f, GetControlRotation().Yaw, 0.f); AddMovementInput(YawRot.Vector(), Axis.Y); // 前后 AddMovementInput(FRotationMatrix(YawRot).GetUnitAxis(EAxis::Y), Axis.X); // 左右 }
结果:编译后从 BP_MyCharacter 创建蓝图子类,填入模型与动画蓝图,拖进关卡。WASD 移动、空格跳跃,速度在蓝图与编辑器里都能调。
解读:注意三处设计意图。组件在构造函数创建而非 BeginPlay,是引擎的默认子对象机制——编辑器在类实例化时就要看到组件树;AddMovementInput 而非直接改位置,是把运动学交给内置移动组件,碰撞、台阶、网络同步全部免费获得;速度属性暴露给编辑器与蓝图,调手感的人不需要进 C++。变式一:冲刺需求——按住某键把速度切到一千二,一个输入动作加一个属性切换即可,不用新写移动逻辑。变式二:鼠标转视角绑定到控制器的 Yaw 与 Pitch 输入轴,弹簧臂的跟随旋转属性已经替你把镜头连上了。
常见坑:在 Tick 里每帧 GetPlayerController 取控制器再取视角。控制器指针缓存在 BeginPlay 里取一次即可,逐帧的查找链是白花的预算。