Skip to Content

34e - 游戏 Steering 规则与反模式

本文是《AI Agent 实战手册》第 34 章第 5 节。 上一节:AI生成游戏资产 | 下一节:AI辅助嵌入式开发概览

概述

游戏开发是 AI 代码生成最容易产生”隐性 Bug”的领域——AI 生成的代码在编辑器中编译通过、在低负载下运行正常,但一旦进入实际游戏场景就会暴露物理抖动、帧率骤降、内存泄漏、输入丢失、渲染卡顿等严重问题。2025-2026 年的实践数据表明,AI 生成的游戏代码中约 50-65% 存在性能或正确性问题,远高于 Web 应用的 30-40%,根本原因在于游戏开发对实时性、确定性和资源效率有极其严格的要求,而这些约束很难通过自然语言 prompt 完整传达。本节提供完整的游戏开发 Steering 规则模板(覆盖 Unity C#、Godot GDScript、Unreal C++)、五大核心反模式的深度剖析(含 AI 生成的问题代码 vs 正确代码对比)、引擎专用 Steering 规则,以及实战案例和避坑指南,帮助开发者在使用 AI 辅助游戏开发时避免常见陷阱。


1. 游戏开发 Steering 规则概述

1.1 为什么游戏开发需要专用 Steering 规则

游戏开发与传统应用开发有本质区别,AI 在以下方面特别容易犯错:

维度Web/应用开发游戏开发AI 常见失误
时间模型事件驱动,无严格时序固定帧率循环,物理需确定性在 Update 中写物理逻辑、不用 delta time
内存管理GC 友好,分配自由每帧预算严格,GC 暂停致命Update 中 new 对象、字符串拼接、LINQ
性能预算响应时间 < 200ms 即可每帧 16.67ms(60fps)硬性约束不考虑 draw call、overdraw、shader 复杂度
对象生命周期创建-使用-GC 回收对象池、延迟销毁、场景切换不检查 null/freed、事件未取消订阅
输入处理表单提交、点击事件每帧轮询、缓冲、预测在渲染帧处理物理输入、缺少输入缓冲
渲染管线浏览器/框架处理开发者需主动优化不合批、过多材质、复杂 shader
多线程异步/await 即可主线程/渲染线程/物理线程分离跨线程访问游戏对象、竞态条件
平台差异浏览器差异较小PC/主机/移动端性能差异巨大只在编辑器测试、不考虑低端设备

1.2 Steering 规则工具对比

工具规则文件位置价格游戏开发适用性适用场景
Claude CodeCLAUDE.md(项目根目录)Max $100/月起 / API 按量★★★★★大型游戏项目,多文件重构
Kiro.kiro/steering/*.md免费(预览期)★★★★★分层规则,按引擎/模块分区
Cursor.cursor/rules/*.mdc免费 / Pro $20/月★★★★☆日常游戏脚本编写
GitHub Copilot.github/copilot-instructions.md$10/月(Individual)★★★☆☆代码补全级辅助
Windsurf.windsurfrules免费 / Pro $15/月★★★☆☆轻量级游戏开发
JetBrains AI.jb-ai-rules.md$8.33/月起(AI Pro)★★★★☆Rider(Unity C#)/ CLion(UE C++)

1.3 游戏开发 Steering 规则架构

游戏项目根目录/ ├── CLAUDE.md # 全局规则(通用游戏开发约束) ├── .kiro/steering/ │ ├── game-general.md # 游戏通用规则(always) │ ├── unity-csharp.md # Unity C# 专用规则(auto: *.cs) │ ├── godot-gdscript.md # Godot GDScript 专用规则(auto: *.gd) │ ├── unreal-cpp.md # Unreal C++ 专用规则(auto: *.cpp, *.h) │ ├── physics-rules.md # 物理系统规则(manual) │ └── rendering-rules.md # 渲染优化规则(manual) ├── .cursor/rules/ │ ├── game-dev-rules.mdc # Cursor 游戏开发规则 │ └── engine-specific.mdc # 引擎特定规则 └── .github/ └── copilot-instructions.md # Copilot 游戏开发指令

2. 完整 Steering 规则模板

2.1 CLAUDE.md 游戏开发通用规则模板

以下是适用于多引擎游戏项目的完整 CLAUDE.md 模板:

# CLAUDE.md — 游戏开发项目规则 ## 项目概述 - 引擎:[Unity 6 LTS / Godot 4.4 / Unreal Engine 5.5] - 语言:[C# 12 / GDScript / C++20] - 目标平台:[PC + Mobile / Console / WebGL] - 目标帧率:60fps(移动端可降至 30fps) - 最低硬件:[具体 GPU/内存要求] ## 🔴 绝对禁止(违反即回退) 1. 禁止在 Update/Tick/_process 中使用 new 分配堆内存(使用对象池或预分配) 2. 禁止在 Update 中使用 Find/GetNode 等搜索方法(在 Start/Ready 中缓存引用) 3. 禁止在 Update 中进行字符串拼接(使用 StringBuilder 或预分配 char[]) 4. 禁止在渲染帧回调中写物理逻辑(物理必须在 FixedUpdate/_physics_process 中) 5. 禁止直接销毁正在被引用的对象(先取消所有引用和事件订阅) 6. 禁止使用 LINQ 查询热路径代码(LINQ 产生大量 GC 分配) 7. 禁止在游戏循环中使用 async/await(使用协程或状态机) 8. 禁止硬编码魔法数字(使用 ScriptableObject/Resource/DataTable) ## 🟡 物理系统规则 - 所有物理计算必须在 FixedUpdate/_physics_process/Tick 中执行 - 移动刚体必须使用 MovePosition/Velocity,禁止直接修改 Transform - 物理射线检测结果必须缓存,禁止每帧重复检测相同目标 - 碰撞检测必须使用层级过滤,禁止全层级检测 - 连续碰撞检测(CCD)必须对高速物体启用,防止穿透 ## 🟢 内存管理规则 - 频繁创建/销毁的对象(子弹、特效、敌人)必须使用对象池 - 协程/Tween 必须在对象销毁前停止 - 事件/信号订阅必须在 OnDestroy/_exit_tree 中取消 - 纹理/音频资源必须按需加载,不用时释放 - 字符串操作使用 StringBuilder(C#)或 StringName(Godot) ## 🔵 输入处理规则 - 输入检测在 Update/_process/_input 中执行(不在物理帧中) - 动作类游戏必须实现输入缓冲(3-5 帧窗口) - 平台跳跃必须实现 Coyote Time(离开平台后 0.1-0.15s 仍可跳跃) - 所有输入必须通过 Input Action 抽象层,禁止硬编码按键 - 移动端必须支持触摸和手柄双输入方案 ## 🟣 渲染优化规则 - 单场景 draw call 不超过 [200(移动端)/ 2000(PC)] - 使用 GPU Instancing / MultiMesh 渲染大量相同物体 - UI 元素必须分层(静态层 + 动态层),避免整体重绘 - Shader 禁止在片元着色器中使用分支语句(if/else) - 透明物体数量严格控制,避免 overdraw - LOD 系统必须对 3D 模型启用(至少 3 级) ## 📁 代码组织规则 - 每个脚本/节点只负责一个职责(单一职责原则) - 游戏状态使用状态机管理,禁止嵌套 if-else 判断游戏状态 - 配置数据与逻辑分离(ScriptableObject / Resource / DataTable) - 公共接口使用事件/信号解耦,禁止直接引用其他系统的内部状态

2.2 Kiro Steering 游戏通用规则模板

文件路径:.kiro/steering/game-general.md

--- trigger: always --- # 游戏开发通用 Steering 规则 ## 帧率预算意识 - 目标:60fps = 每帧 16.67ms 预算 - 脚本逻辑预算:< 5ms - 物理预算:< 4ms - 渲染预算:< 8ms - 任何单个函数执行时间不得超过 2ms ## 热路径零分配原则 以下函数被视为"热路径",禁止在其中分配堆内存: - Update / _process / Tick(每帧调用) - FixedUpdate / _physics_process(每物理帧调用) - LateUpdate(每帧调用) - OnCollision / OnTrigger / _on_body_entered(高频回调) - 动画事件回调 - 输入处理回调 ## 对象生命周期管理 - 创建:优先从对象池获取,池空时才实例化 - 使用:检查对象有效性(IsValid / is_instance_valid / IsValidLowLevel) - 回收:归还对象池而非销毁,重置状态 - 销毁:先断开所有信号/事件连接,再销毁 ## 场景切换安全 - 切换场景前:停止所有协程/Tween、取消所有异步操作、保存必要状态 - 切换场景时:使用加载屏幕,异步加载下一场景 - 切换场景后:验证所有单例引用有效、重新初始化输入状态

2.3 Cursor Rules 游戏开发模板

文件路径:.cursor/rules/game-dev-rules.mdc

--- description: 游戏开发核心规则,适用于所有游戏引擎项目 globs: ["*.cs", "*.gd", "*.cpp", "*.h", "*.hpp"] --- # 游戏开发 Cursor 规则 ## 代码生成约束 1. 生成游戏脚本时,必须区分"每帧逻辑"和"一次性初始化" 2. 所有组件引用必须在初始化函数中缓存,禁止运行时查找 3. 生成物理相关代码时,必须放在固定时间步函数中 4. 生成对象创建代码时,必须提供对象池版本 5. 生成 UI 代码时,必须考虑 draw call 合批 ## 性能审查清单 生成代码后,自动检查以下项目: - [ ] 热路径中是否有 new/Instantiate/instance() - [ ] 是否有未缓存的 GetComponent/get_node 调用 - [ ] 物理逻辑是否在正确的回调函数中 - [ ] 事件订阅是否有对应的取消订阅 - [ ] 是否有字符串拼接在热路径中 - [ ] 是否有 LINQ/lambda 在热路径中

3. 反模式一:物理不稳定(Physics Instability)

3.1 问题概述

物理不稳定是 AI 生成游戏代码中最常见也最难调试的问题。表现为:角色移动抖动(jitter)、物体穿透碰撞体(tunneling)、不同帧率下行为不一致、物理模拟结果不可复现。根本原因是 AI 不理解游戏引擎的时间模型——渲染帧(可变时间步)和物理帧(固定时间步)是两个独立的循环。

问题严重程度: 🔴 致命(直接影响游戏可玩性)

影响范围:

  • 角色控制器:移动速度随帧率变化,高帧率移动快、低帧率移动慢
  • 碰撞检测:高速物体穿透墙壁和地面
  • 物理模拟:弹道、爆炸、布料等效果不可预测
  • 网络同步:不同客户端物理结果不一致

3.2 AI 生成的问题代码

Unity C# — 错误示例

// ❌ AI 常见错误:在 Update 中直接修改物理对象位置 public class PlayerMovement : MonoBehaviour { public float speed = 5f; public float jumpForce = 10f; private Rigidbody rb; void Start() { rb = GetComponent<Rigidbody>(); } // 错误 1:物理移动放在 Update 中(帧率依赖) void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); // 错误 2:直接修改 Transform 而非通过 Rigidbody transform.position += new Vector3(h, 0, v) * speed * Time.deltaTime; // 错误 3:每帧都检测跳跃,没有地面检测缓存 if (Input.GetKeyDown(KeyCode.Space)) { // 错误 4:使用 ForceMode.Force 而非 Impulse 做跳跃 rb.AddForce(Vector3.up * jumpForce, ForceMode.Force); } // 错误 5:每帧创建新的 RaycastHit(GC 分配) RaycastHit hit; if (Physics.Raycast(transform.position, Vector3.down, out hit, 1.1f)) { Debug.Log("Grounded: " + hit.collider.name); // 错误 6:热路径字符串拼接 } } }

问题分析:

错误编号问题后果
1物理逻辑在 Update 中帧率不同导致移动速度不一致
2直接修改 Transform绕过物理引擎,碰撞检测失效
3无地面检测缓存每帧射线检测浪费性能
4ForceMode.Force 做跳跃跳跃力度随物理帧率变化
5热路径 GC 分配频繁 GC 导致帧率卡顿
6热路径字符串拼接每帧产生垃圾字符串

Unity C# — 正确示例

// ✅ 正确:物理逻辑在 FixedUpdate,输入在 Update,引用预缓存 public class PlayerMovement : MonoBehaviour { [SerializeField] private float speed = 5f; [SerializeField] private float jumpForce = 10f; [SerializeField] private LayerMask groundLayer; [SerializeField] private float groundCheckDistance = 0.2f; private Rigidbody _rb; private CapsuleCollider _collider; private Vector3 _moveInput; private bool _jumpRequested; private bool _isGrounded; private readonly RaycastHit[] _groundHits = new RaycastHit[1]; // 预分配 void Awake() { _rb = GetComponent<Rigidbody>(); _collider = GetComponent<CapsuleCollider>(); // 启用连续碰撞检测,防止高速穿透 _rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; _rb.interpolation = RigidbodyInterpolation.Interpolate; // 平滑渲染 } // 输入在 Update 中采集(响应性最佳) void Update() { _moveInput = new Vector3( Input.GetAxis("Horizontal"), 0f, Input.GetAxis("Vertical") ); // 跳跃请求缓存,在 FixedUpdate 中消费 if (Input.GetButtonDown("Jump") && _isGrounded) { _jumpRequested = true; } } // 物理逻辑在 FixedUpdate 中执行(固定时间步,确定性) void FixedUpdate() { // 地面检测:使用 NonAlloc 版本避免 GC float radius = _collider.radius * 0.9f; Vector3 origin = transform.position + Vector3.up * radius; _isGrounded = Physics.SphereCastNonAlloc( origin, radius, Vector3.down, _groundHits, groundCheckDistance, groundLayer ) > 0; // 通过 Rigidbody 移动(物理引擎处理碰撞) Vector3 targetVelocity = _moveInput.normalized * speed; targetVelocity.y = _rb.linearVelocity.y; // 保持垂直速度 _rb.linearVelocity = targetVelocity; // 消费跳跃请求 if (_jumpRequested) { _rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); _jumpRequested = false; } } }

Godot GDScript — 错误示例

# ❌ AI 常见错误:在 _process 中写物理移动 extends CharacterBody3D var speed = 5.0 var jump_force = 10.0 # 错误 1:物理移动放在 _process 中(帧率依赖) func _process(delta): var direction = Vector3.ZERO # 错误 2:硬编码按键而非使用 Input Map if Input.is_key_pressed(KEY_W): direction.z -= 1 if Input.is_key_pressed(KEY_S): direction.z += 1 if Input.is_key_pressed(KEY_A): direction.x -= 1 if Input.is_key_pressed(KEY_D): direction.x += 1 # 错误 3:没有归一化方向向量(对角线移动更快) velocity = direction * speed # 错误 4:重力处理不正确(没有累积) if not is_on_floor(): velocity.y = -9.8 # 错误 5:跳跃没有地面检测保护 if Input.is_key_pressed(KEY_SPACE): velocity.y = jump_force move_and_slide()

Godot GDScript — 正确示例

# ✅ 正确:物理移动在 _physics_process,输入使用 Input Map extends CharacterBody3D @export var speed: float = 5.0 @export var jump_force: float = 10.0 @export var gravity: float = 20.0 @export var coyote_time: float = 0.12 # 土狼时间 var _coyote_timer: float = 0.0 var _jump_buffered: bool = false var _jump_buffer_timer: float = 0.0 const JUMP_BUFFER_TIME: float = 0.1 # 输入采集在 _unhandled_input 中(事件驱动,更高效) func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("jump"): _jump_buffered = true _jump_buffer_timer = JUMP_BUFFER_TIME # 物理逻辑在 _physics_process 中(固定时间步) func _physics_process(delta: float) -> void: # 重力累积(正确的物理模拟) if not is_on_floor(): velocity.y -= gravity * delta _coyote_timer -= delta else: _coyote_timer = coyote_time # 跳跃缓冲计时器 if _jump_buffered: _jump_buffer_timer -= delta if _jump_buffer_timer <= 0.0: _jump_buffered = false # 跳跃:支持 Coyote Time + 输入缓冲 if _jump_buffered and _coyote_timer > 0.0: velocity.y = jump_force _jump_buffered = false _coyote_timer = 0.0 # 使用 Input Map 的 Action(可重映射) var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back") var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() velocity.x = direction.x * speed velocity.z = direction.z * speed move_and_slide()

Unreal C++ — 错误示例

// ❌ AI 常见错误:在 Tick 中直接设置 Actor 位置 void AMyCharacter::Tick(float DeltaTime) { Super::Tick(DeltaTime); // 错误 1:直接修改位置,绕过物理 FVector NewLocation = GetActorLocation(); NewLocation += GetActorForwardVector() * Speed * DeltaTime; SetActorLocation(NewLocation); // 穿透碰撞体! // 错误 2:每帧创建 FString(堆分配) FString DebugMsg = FString::Printf(TEXT("Pos: %s"), *NewLocation.ToString()); GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::Green, DebugMsg); }

Unreal C++ — 正确示例

// ✅ 正确:使用 CharacterMovementComponent,物理由引擎管理 void AMyCharacter::Tick(float DeltaTime) { Super::Tick(DeltaTime); // 通过 MovementComponent 移动(自动处理碰撞和物理) if (InputVector.SizeSquared() > KINDA_SMALL_NUMBER) { FVector WorldDirection = FRotationMatrix(GetControlRotation()) .GetScaledAxis(EAxis::X) * InputVector.X + FRotationMatrix(GetControlRotation()) .GetScaledAxis(EAxis::Y) * InputVector.Y; AddMovementInput(WorldDirection.GetSafeNormal()); } // 调试信息仅在 Debug 构建中输出 #if !UE_BUILD_SHIPPING if (GEngine && bShowDebug) { GEngine->AddOnScreenDebugMessage(1, 0.f, FColor::Green, FString::Printf(TEXT("Speed: %.1f"), GetVelocity().Size())); } #endif } // 跳跃使用 CharacterMovementComponent 内置功能 void AMyCharacter::Jump() { if (GetCharacterMovement()->IsMovingOnGround()) { ACharacter::Jump(); } }

3.3 物理不稳定 Steering 规则

## 物理系统 Steering 规则 ### 时间步规则 - 物理计算必须在固定时间步回调中: - Unity: FixedUpdate() - Godot: _physics_process(delta) - Unreal: 使用 CharacterMovementComponent 或 Physics Tick - 渲染相关(摄像机跟随、UI 更新)在可变时间步回调中: - Unity: Update() / LateUpdate() - Godot: _process(delta) - Unreal: Tick(DeltaTime) ### 碰撞检测规则 - 高速物体(子弹、投射物)必须启用 CCD: - Unity: CollisionDetectionMode.ContinuousDynamic - Godot: CCD 模式启用 - Unreal: bUseCCD = true - 射线检测使用 NonAlloc/预分配数组版本 - 碰撞层级必须正确配置,避免不必要的碰撞对 ### 刚体操作规则 - 禁止直接修改 Transform/Position 移动物理对象 - 使用引擎提供的物理移动方法: - Unity: Rigidbody.MovePosition / velocity - Godot: CharacterBody.move_and_slide / velocity - Unreal: AddMovementInput / LaunchCharacter - 启用插值(Interpolation)平滑渲染表现

4. 反模式二:Update 循环中分配内存(Allocation in Update Loops)

4.1 问题概述

在游戏的每帧更新循环中分配堆内存是 AI 生成代码中最普遍的性能杀手。在托管语言(C#/GDScript)中,频繁的堆分配会触发垃圾回收(GC),导致不可预测的帧率卡顿(GC spike);在 C++ 中,频繁的 malloc/free 会导致内存碎片化和缓存未命中。一次 GC 暂停可能持续 5-50ms,在 60fps 游戏中意味着丢失 1-3 帧,玩家会明显感受到”卡顿”。

问题严重程度: 🔴 致命(直接导致帧率不稳定)

常见 GC 分配来源(AI 最容易犯的错误):

分配来源每帧分配量累积影响
new 创建对象24-100+ bytes每秒 60 次 = 1.4-6 KB/s
字符串拼接 "HP: " + hp40-200 bytesUI 更新时极其频繁
LINQ 查询 .Where().ToList()100-500 bytes迭代器 + 列表分配
Lambda/闭包32-64 bytes每次创建新委托对象
foreach 在某些集合上32 bytes装箱的枚举器
GetComponent<T>() 每帧调用间接分配反射查找开销
ToString()20-100 bytes数值转字符串
数组/列表创建40+ bytesnew List<T>()

4.2 AI 生成的问题代码

Unity C# — 错误示例

// ❌ AI 常见错误:Update 中充满 GC 分配 public class EnemyManager : MonoBehaviour { public GameObject enemyPrefab; public Text scoreText; private int score = 0; void Update() { // 错误 1:每帧用 LINQ 查找存活敌人(分配迭代器 + 列表) var aliveEnemies = FindObjectsOfType<Enemy>() .Where(e => e.IsAlive) .OrderBy(e => Vector3.Distance(transform.position, e.transform.position)) .ToList(); // 错误 2:每帧创建新列表存储结果 List<Enemy> nearbyEnemies = new List<Enemy>(); foreach (var enemy in aliveEnemies) { if (Vector3.Distance(transform.position, enemy.transform.position) < 10f) { nearbyEnemies.Add(enemy); } } // 错误 3:字符串拼接更新 UI(每帧产生垃圾字符串) scoreText.text = "Score: " + score.ToString() + " | Enemies: " + nearbyEnemies.Count.ToString(); // 错误 4:每帧实例化和销毁粒子(应该用对象池) if (nearbyEnemies.Count > 0) { foreach (var enemy in nearbyEnemies) { // 每帧为每个敌人创建新的伤害指示器 var indicator = Instantiate(enemyPrefab, enemy.transform.position, Quaternion.identity); Destroy(indicator, 1f); } } // 错误 5:Lambda 在热路径中(每次创建新委托) nearbyEnemies.ForEach(e => e.TakeDamage(1)); } }

Unity C# — 正确示例

// ✅ 正确:零分配 Update,对象池,缓存引用 public class EnemyManager : MonoBehaviour { [SerializeField] private GameObject indicatorPrefab; [SerializeField] private TMP_Text scoreText; // TextMeshPro 更高效 // 预分配集合(避免每帧 new) private readonly List<Enemy> _aliveEnemies = new(64); private readonly List<Enemy> _nearbyEnemies = new(32); // 对象池 private ObjectPool<GameObject> _indicatorPool; // UI 更新节流 private int _lastScore = -1; private int _lastEnemyCount = -1; private readonly StringBuilder _sb = new(64); // 缓存的敌人注册表(避免 FindObjectsOfType) private static readonly HashSet<Enemy> _registeredEnemies = new(128); public static void RegisterEnemy(Enemy enemy) => _registeredEnemies.Add(enemy); public static void UnregisterEnemy(Enemy enemy) => _registeredEnemies.Remove(enemy); private int _score; void Awake() { _indicatorPool = new ObjectPool<GameObject>( createFunc: () => Instantiate(indicatorPrefab), actionOnGet: obj => obj.SetActive(true), actionOnRelease: obj => obj.SetActive(false), defaultCapacity: 20, maxSize: 50 ); } void Update() { // 零分配:复用预分配列表 _aliveEnemies.Clear(); _nearbyEnemies.Clear(); Vector3 myPos = transform.position; float nearbyDistSq = 10f * 10f; // 预计算平方距离(避免 sqrt) // 零分配遍历:直接遍历 HashSet foreach (Enemy enemy in _registeredEnemies) { if (!enemy.IsAlive) continue; _aliveEnemies.Add(enemy); // 使用平方距离比较(避免 Vector3.Distance 的 sqrt) float distSq = (myPos - enemy.transform.position).sqrMagnitude; if (distSq < nearbyDistSq) { _nearbyEnemies.Add(enemy); } } // UI 更新:仅在数据变化时更新(避免每帧字符串操作) if (_score != _lastScore || _nearbyEnemies.Count != _lastEnemyCount) { _sb.Clear(); _sb.Append("Score: ").Append(_score) .Append(" | Enemies: ").Append(_nearbyEnemies.Count); scoreText.SetText(_sb); _lastScore = _score; _lastEnemyCount = _nearbyEnemies.Count; } // 对象池:获取而非实例化 for (int i = 0; i < _nearbyEnemies.Count; i++) { Enemy enemy = _nearbyEnemies[i]; GameObject indicator = _indicatorPool.Get(); indicator.transform.position = enemy.transform.position; // 延迟归还对象池 StartCoroutine(ReturnToPoolAfter(indicator, 1f)); enemy.TakeDamage(1); // 直接调用,无 Lambda } } private System.Collections.IEnumerator ReturnToPoolAfter(GameObject obj, float delay) { yield return new WaitForSeconds(delay); _indicatorPool.Release(obj); } void OnDestroy() { _indicatorPool?.Dispose(); } }

Godot GDScript — 错误示例

# ❌ AI 常见错误:_process 中频繁创建临时对象 extends Node2D @onready var score_label = $ScoreLabel var score: int = 0 func _process(delta): # 错误 1:每帧获取所有节点(遍历整棵树) var enemies = get_tree().get_nodes_in_group("enemies") # 错误 2:每帧创建新数组 var nearby = [] for enemy in enemies: # 错误 3:每帧计算距离(未使用平方距离) var dist = global_position.distance_to(enemy.global_position) if dist < 200.0: nearby.append(enemy) # 错误 4:每帧排序(O(n log n) 每帧) nearby.sort_custom(func(a, b): return global_position.distance_to(a.global_position) < global_position.distance_to(b.global_position) ) # 错误 5:每帧更新 UI 文本(即使没变化) score_label.text = "Score: " + str(score) + " | Nearby: " + str(nearby.size()) # 错误 6:每帧实例化场景 for enemy in nearby: var bullet = preload("res://bullet.tscn").instantiate() add_child(bullet) bullet.global_position = global_position

Godot GDScript — 正确示例

# ✅ 正确:预分配、对象池、节流更新 extends Node2D @onready var score_label: Label = $ScoreLabel @export var nearby_distance: float = 200.0 @export var pool_size: int = 20 var score: int = 0 var _nearby_dist_sq: float # 预计算平方距离 var _bullet_pool: Array[Node2D] = [] var _pool_index: int = 0 var _last_score: int = -1 var _last_nearby_count: int = -1 # 预加载场景(避免每帧 preload) var _bullet_scene: PackedScene = preload("res://bullet.tscn") func _ready() -> void: _nearby_dist_sq = nearby_distance * nearby_distance # 预创建对象池 for i in pool_size: var bullet := _bullet_scene.instantiate() as Node2D bullet.visible = false bullet.set_process(false) bullet.set_physics_process(false) add_child(bullet) _bullet_pool.append(bullet) func _process(delta: float) -> void: var my_pos := global_position var nearby_count := 0 # 使用 group 遍历(引擎内部优化,比 get_nodes_in_group 更高效) for enemy in get_tree().get_nodes_in_group(&"enemies"): if not enemy is Node2D: continue # 平方距离比较(避免 sqrt) var diff: Vector2 = my_pos - (enemy as Node2D).global_position if diff.length_squared() < _nearby_dist_sq: nearby_count += 1 _fire_at(enemy as Node2D) # 仅在数据变化时更新 UI if score != _last_score or nearby_count != _last_nearby_count: score_label.text = "Score: %d | Nearby: %d" % [score, nearby_count] _last_score = score _last_nearby_count = nearby_count func _fire_at(target: Node2D) -> void: # 从对象池获取 var bullet := _bullet_pool[_pool_index] _pool_index = (_pool_index + 1) % _bullet_pool.size() bullet.global_position = global_position bullet.visible = true bullet.set_process(true) bullet.set_physics_process(true) # bullet 脚本负责自己的回收逻辑

4.3 内存分配 Steering 规则

## 内存管理 Steering 规则 ### 零分配热路径原则 以下函数中禁止任何堆内存分配: - Update / _process / Tick - FixedUpdate / _physics_process - LateUpdate - 碰撞/触发回调 - 动画事件回调 ### 允许的替代方案 | 禁止操作 | 替代方案 | |---------|---------| | `new List<T>()` | 预分配 + Clear() 复用 | | `Instantiate()` / `.instantiate()` | 对象池 | | `"str" + var` | StringBuilder / `%d` 格式化 | | `FindObjectsOfType()` | 注册表模式(Register/Unregister) | | `.Where().ToList()` | for 循环 + 预分配列表 | | `GetComponent<T>()` 每帧 | Awake/Start 中缓存 | | `Vector3.Distance()` | sqrMagnitude 比较 | | `new WaitForSeconds()` | 缓存 YieldInstruction | ### UI 更新规则 - UI 文本仅在数据变化时更新(脏标记模式) - 使用 TextMeshPro(Unity)/ RichTextLabel(Godot) - 数值显示使用预格式化,避免每帧 ToString()

5. 反模式三:事件生命周期错误(Event Lifecycle Errors)

5.1 问题概述

事件生命周期错误是 AI 生成游戏代码中最隐蔽的 Bug 类型。AI 非常擅长订阅事件和连接信号,但几乎从不生成对应的取消订阅代码。这导致三类严重问题:

  1. 悬空引用(Dangling References):对象已销毁,但事件回调仍然引用它,触发时导致空引用异常或访问已释放内存
  2. 未取消订阅的事件(Unsubscribed Events):对象销毁后事件仍在触发,导致内存泄漏和幽灵行为
  3. 已销毁对象的回调(Destroyed Object Callbacks):协程、Tween、定时器在对象销毁后继续执行

问题严重程度: 🔴 致命(导致崩溃、内存泄漏、不可预测行为)

AI 生成代码中事件生命周期错误的典型场景:

对象 A 订阅了对象 B 的事件 对象 B 被销毁(场景切换/对象池回收/游戏逻辑) 事件触发 → 回调尝试访问已销毁的对象 B 💥 NullReferenceException / 访问已释放内存 / 幽灵行为

5.2 AI 生成的问题代码

Unity C# — 错误示例

// ❌ AI 常见错误:订阅事件但从不取消订阅 public class HealthBar : MonoBehaviour { private Enemy _targetEnemy; private Slider _slider; void Start() { _slider = GetComponent<Slider>(); _targetEnemy = GetComponentInParent<Enemy>(); // 错误 1:订阅事件但没有对应的取消订阅 _targetEnemy.OnHealthChanged += UpdateHealthBar; _targetEnemy.OnDeath += HandleEnemyDeath; // 错误 2:订阅全局事件管理器 GameEvents.OnGamePaused += HandlePause; GameEvents.OnGameResumed += HandleResume; } void UpdateHealthBar(float healthPercent) { // 错误 3:没有检查对象是否已销毁 _slider.value = healthPercent; } void HandleEnemyDeath() { // 错误 4:销毁自己但没有先取消事件订阅 Destroy(gameObject); // 此时 OnHealthChanged 仍然引用着已销毁的 UpdateHealthBar // 下次触发时 → NullReferenceException } void HandlePause() { /* ... */ } void HandleResume() { /* ... */ } // 错误 5:完全没有 OnDestroy 清理逻辑 } // ❌ AI 常见错误:协程在对象销毁后继续执行 public class SpawnManager : MonoBehaviour { void Start() { // 错误 6:启动协程但不跟踪引用 StartCoroutine(SpawnEnemiesForever()); InvokeRepeating("CheckSpawnConditions", 0f, 1f); } IEnumerator SpawnEnemiesForever() { while (true) { // 错误 7:协程中引用可能已销毁的对象 var spawnPoint = GameObject.Find("SpawnPoint"); // 每次查找! if (spawnPoint != null) { Instantiate(enemyPrefab, spawnPoint.transform.position, Quaternion.identity); } yield return new WaitForSeconds(2f); // 错误 8:每次创建新 WaitForSeconds } } }

Unity C# — 正确示例

// ✅ 正确:完整的事件生命周期管理 public class HealthBar : MonoBehaviour { private Enemy _targetEnemy; private Slider _slider; private bool _isSubscribed; void Start() { _slider = GetComponent<Slider>(); _targetEnemy = GetComponentInParent<Enemy>(); if (_targetEnemy != null) { SubscribeEvents(); } } private void SubscribeEvents() { if (_isSubscribed || _targetEnemy == null) return; _targetEnemy.OnHealthChanged += UpdateHealthBar; _targetEnemy.OnDeath += HandleEnemyDeath; GameEvents.OnGamePaused += HandlePause; GameEvents.OnGameResumed += HandleResume; _isSubscribed = true; } private void UnsubscribeEvents() { if (!_isSubscribed) return; // 安全取消订阅(即使目标已销毁,-= 不会抛异常) if (_targetEnemy != null) { _targetEnemy.OnHealthChanged -= UpdateHealthBar; _targetEnemy.OnDeath -= HandleEnemyDeath; } GameEvents.OnGamePaused -= HandlePause; GameEvents.OnGameResumed -= HandleResume; _isSubscribed = false; } void UpdateHealthBar(float healthPercent) { // 安全检查:确保自身和 UI 组件仍然有效 if (this == null || _slider == null) return; _slider.value = healthPercent; } void HandleEnemyDeath() { UnsubscribeEvents(); // 先取消订阅 Destroy(gameObject); // 再销毁 } void HandlePause() { /* ... */ } void HandleResume() { /* ... */ } // 关键:OnDestroy 中清理所有订阅 void OnDestroy() { UnsubscribeEvents(); } // 额外安全:OnDisable 时也取消订阅(对象池场景) void OnDisable() { UnsubscribeEvents(); } void OnEnable() { SubscribeEvents(); } } // ✅ 正确:协程生命周期管理 public class SpawnManager : MonoBehaviour { [SerializeField] private Transform _spawnPoint; // 编辑器引用,不运行时查找 [SerializeField] private GameObject _enemyPrefab; private Coroutine _spawnCoroutine; private static readonly WaitForSeconds _spawnDelay = new(2f); // 缓存 YieldInstruction void OnEnable() { _spawnCoroutine = StartCoroutine(SpawnLoop()); } void OnDisable() { // 停止协程,防止对象禁用后继续执行 if (_spawnCoroutine != null) { StopCoroutine(_spawnCoroutine); _spawnCoroutine = null; } CancelInvoke(); // 停止所有 InvokeRepeating } private IEnumerator SpawnLoop() { while (true) { if (_spawnPoint != null) // 安全检查 { // 使用对象池而非 Instantiate var enemy = EnemyPool.Instance.Get(); enemy.transform.position = _spawnPoint.position; } yield return _spawnDelay; // 复用缓存的 WaitForSeconds } } }

Godot GDScript — 错误示例

# ❌ AI 常见错误:信号连接但不断开 extends Control var target_enemy: Node func _ready(): target_enemy = get_parent() # 错误 1:连接信号但不在 _exit_tree 中断开 target_enemy.health_changed.connect(_on_health_changed) target_enemy.died.connect(_on_enemy_died) # 错误 2:连接全局信号 GameEvents.game_paused.connect(_on_game_paused) # 错误 3:创建 Tween 但不跟踪 var tween = create_tween() tween.tween_property(self, "modulate:a", 1.0, 0.5) func _on_health_changed(percent: float): # 错误 4:没有检查节点是否仍在树中 $HealthBar.value = percent func _on_enemy_died(): # 错误 5:直接 queue_free 但信号仍然连接 queue_free() func _on_game_paused(): pass

Godot GDScript — 正确示例

# ✅ 正确:完整的信号生命周期管理 extends Control var _target_enemy: Node var _active_tween: Tween func _ready() -> void: _target_enemy = get_parent() _subscribe_signals() func _subscribe_signals() -> void: if _target_enemy == null: return # 使用 CONNECT_ONE_SHOT 或手动管理 if not _target_enemy.health_changed.is_connected(_on_health_changed): _target_enemy.health_changed.connect(_on_health_changed) if not _target_enemy.died.is_connected(_on_enemy_died): _target_enemy.died.connect(_on_enemy_died) if not GameEvents.game_paused.is_connected(_on_game_paused): GameEvents.game_paused.connect(_on_game_paused) func _unsubscribe_signals() -> void: # 安全断开:先检查连接是否存在 if _target_enemy != null and is_instance_valid(_target_enemy): if _target_enemy.health_changed.is_connected(_on_health_changed): _target_enemy.health_changed.disconnect(_on_health_changed) if _target_enemy.died.is_connected(_on_enemy_died): _target_enemy.died.disconnect(_on_enemy_died) if GameEvents.game_paused.is_connected(_on_game_paused): GameEvents.game_paused.disconnect(_on_game_paused) func _on_health_changed(percent: float) -> void: # 安全检查:确保节点仍在场景树中 if not is_inside_tree(): return var health_bar := get_node_or_null("HealthBar") as ProgressBar if health_bar != null: health_bar.value = percent func _on_enemy_died() -> void: _unsubscribe_signals() # 先断开信号 _kill_tweens() # 停止所有 Tween queue_free() # 再释放 func _kill_tweens() -> void: if _active_tween != null and _active_tween.is_valid(): _active_tween.kill() _active_tween = null func _on_game_paused() -> void: pass # 关键:退出场景树时清理 func _exit_tree() -> void: _unsubscribe_signals() _kill_tweens()

Unreal C++ — 错误示例

// ❌ AI 常见错误:绑定委托但不解绑 void AHealthWidget::BeginPlay() { Super::BeginPlay(); // 错误 1:绑定但没有在 EndPlay 中解绑 if (AActor* Owner = GetOwner()) { Owner->OnTakeAnyDamage.AddDynamic(this, &AHealthWidget::OnDamageTaken); } // 错误 2:绑定到全局委托 UGameplayStatics::GetGameMode(this)->OnGamePaused.AddDynamic( this, &AHealthWidget::OnPaused); // 错误 3:使用 Timer 但不清理 GetWorldTimerManager().SetTimer( UpdateHandle, this, &AHealthWidget::UpdateDisplay, 0.1f, true); } // 错误 4:没有 EndPlay / BeginDestroy 清理

Unreal C++ — 正确示例

// ✅ 正确:完整的委托生命周期管理 void AHealthWidget::BeginPlay() { Super::BeginPlay(); OwnerRef = GetOwner(); // 使用弱引用跟踪 if (OwnerRef.IsValid()) { OwnerRef->OnTakeAnyDamage.AddDynamic(this, &AHealthWidget::OnDamageTaken); } GetWorldTimerManager().SetTimer( UpdateTimerHandle, this, &AHealthWidget::UpdateDisplay, 0.1f, true); } void AHealthWidget::EndPlay(const EEndPlayReason::Type EndPlayReason) { // 清理所有委托绑定 if (OwnerRef.IsValid()) { OwnerRef->OnTakeAnyDamage.RemoveDynamic(this, &AHealthWidget::OnDamageTaken); } // 清理定时器 GetWorldTimerManager().ClearTimer(UpdateTimerHandle); Super::EndPlay(EndPlayReason); } void AHealthWidget::OnDamageTaken(AActor* DamagedActor, float Damage, const UDamageType* DamageType, AController* InstigatedBy, AActor* DamageCauser) { // 安全检查 if (!IsValid(this) || !IsValid(DamagedActor)) return; UpdateDisplay(); }

5.3 事件生命周期 Steering 规则

## 事件生命周期 Steering 规则 ### 订阅/取消订阅配对原则 每一个事件订阅必须有对应的取消订阅: - Unity: OnEnable ↔ OnDisable,或 Start ↔ OnDestroy - Godot: _ready ↔ _exit_tree - Unreal: BeginPlay ↔ EndPlay ### 安全检查规则 - 回调函数第一行必须检查 this/self 是否有效 - 访问其他对象前必须检查引用有效性: - Unity: obj != null(Unity 重载了 == 运算符) - Godot: is_instance_valid(obj) 和 is_inside_tree() - Unreal: IsValid(obj) 或 TWeakObjectPtr ### 协程/Tween/Timer 清理规则 - 所有协程引用必须保存,在 OnDisable/OnDestroy 中停止 - 所有 Tween 必须在对象销毁前 Kill/kill - 所有 Timer 必须在 EndPlay/_exit_tree 中清除 - 禁止使用"fire and forget"模式的异步操作 ### 对象池场景的特殊规则 - 对象归还池时必须断开所有信号/事件连接 - 对象从池中取出时重新建立连接 - 使用 OnEnable/OnDisable(Unity)或自定义 activate/deactivate 方法

6. 反模式四:输入处理差(Poor Input Handling)

6.1 问题概述

输入处理是游戏”手感”的核心,也是 AI 生成代码中最容易被忽视的领域。AI 通常能生成”功能正确”的输入代码——按下按键角色会移动——但缺少让游戏”感觉好”的关键细节:输入缓冲、Coyote Time、输入预测、平台适配、可重映射等。差的输入处理会让玩家觉得游戏”不跟手”、“迟钝”、“不精确”,即使其他方面都很优秀。

问题严重程度: 🟡 严重(直接影响玩家体验和留存率)

AI 输入处理常见问题清单:

问题表现影响
无输入缓冲玩家必须精确到帧按键动作游戏不可玩
无 Coyote Time平台边缘跳跃失败平台跳跃体验极差
硬编码按键无法重映射无障碍性差,手柄不支持
输入在错误的回调中物理帧中检测输入输入丢失或延迟
无死区处理摇杆漂移角色自动移动
无平台适配只支持键鼠手柄/触屏用户无法游玩
输入与帧率耦合高帧率输入过快不同设备体验不一致

6.2 AI 生成的问题代码

Unity C# — 错误示例

// ❌ AI 常见错误:最基础的输入处理,缺少所有"手感"优化 public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; public float jumpForce = 10f; private Rigidbody2D rb; private bool isGrounded; void Start() { rb = GetComponent<Rigidbody2D>(); } void Update() { // 错误 1:硬编码按键(无法重映射,不支持手柄) float moveX = 0; if (Input.GetKey(KeyCode.A)) moveX = -1; if (Input.GetKey(KeyCode.D)) moveX = 1; // 错误 2:没有加速/减速曲线(移动感觉生硬) rb.linearVelocity = new Vector2(moveX * moveSpeed, rb.linearVelocity.y); // 错误 3:简单的地面检测 + 跳跃(无 Coyote Time,无输入缓冲) isGrounded = Physics2D.Raycast(transform.position, Vector2.down, 1.1f); if (Input.GetKeyDown(KeyCode.Space) && isGrounded) { rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce); } // 错误 4:没有处理跳跃高度可变(短按 vs 长按) // 错误 5:没有处理摇杆死区 // 错误 6:没有输入缓冲(玩家稍早按跳跃就无效) } }

Unity C# — 正确示例

// ✅ 正确:完整的输入处理系统(缓冲、Coyote Time、可变跳跃高度) public class PlayerController : MonoBehaviour { [Header("Movement")] [SerializeField] private float maxSpeed = 8f; [SerializeField] private float acceleration = 50f; [SerializeField] private float deceleration = 40f; [SerializeField] private float airAcceleration = 25f; [Header("Jump")] [SerializeField] private float jumpForce = 12f; [SerializeField] private float jumpCutMultiplier = 0.4f; // 短按跳跃高度衰减 [SerializeField] private float coyoteTime = 0.12f; [SerializeField] private float jumpBufferTime = 0.1f; [SerializeField] private float fallGravityMultiplier = 1.5f; [Header("Ground Check")] [SerializeField] private LayerMask groundLayer; [SerializeField] private Vector2 groundCheckSize = new(0.8f, 0.05f); [SerializeField] private Transform groundCheckPoint; private Rigidbody2D _rb; private PlayerInputActions _input; // 使用 Input System(可重映射) // 状态 private float _coyoteTimer; private float _jumpBufferTimer; private bool _isGrounded; private bool _isJumping; private float _moveInput; void Awake() { _rb = GetComponent<Rigidbody2D>(); _input = new PlayerInputActions(); } void OnEnable() { _input.Enable(); _input.Gameplay.Jump.started += OnJumpStarted; _input.Gameplay.Jump.canceled += OnJumpCanceled; } void OnDisable() { _input.Gameplay.Jump.started -= OnJumpStarted; _input.Gameplay.Jump.canceled -= OnJumpCanceled; _input.Disable(); } // 输入事件回调(Input System 自动处理平台差异) private void OnJumpStarted(UnityEngine.InputSystem.InputAction.CallbackContext ctx) { _jumpBufferTimer = jumpBufferTime; // 缓冲跳跃请求 } private void OnJumpCanceled(UnityEngine.InputSystem.InputAction.CallbackContext ctx) { // 短按跳跃:释放按键时削减上升速度 if (_isJumping && _rb.linearVelocity.y > 0) { _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _rb.linearVelocity.y * jumpCutMultiplier); } } void Update() { // 读取移动输入(Input System 自动处理死区) _moveInput = _input.Gameplay.Move.ReadValue<Vector2>().x; // 更新计时器 _jumpBufferTimer -= Time.deltaTime; // 地面检测 _isGrounded = Physics2D.OverlapBox( groundCheckPoint.position, groundCheckSize, 0f, groundLayer); if (_isGrounded) { _coyoteTimer = coyoteTime; _isJumping = false; } else { _coyoteTimer -= Time.deltaTime; } } void FixedUpdate() { // 水平移动:带加速/减速曲线 float targetSpeed = _moveInput * maxSpeed; float accelRate = _isGrounded ? (Mathf.Abs(targetSpeed) > 0.01f ? acceleration : deceleration) : airAcceleration; float speedDiff = targetSpeed - _rb.linearVelocity.x; float movement = speedDiff * accelRate * Time.fixedDeltaTime; _rb.AddForce(Vector2.right * movement, ForceMode2D.Force); // 跳跃:Coyote Time + 输入缓冲 if (_jumpBufferTimer > 0 && _coyoteTimer > 0) { _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, jumpForce); _jumpBufferTimer = 0; _coyoteTimer = 0; _isJumping = true; } // 下落加速(更好的跳跃手感) if (_rb.linearVelocity.y < 0) { _rb.gravityScale = fallGravityMultiplier; } else { _rb.gravityScale = 1f; } } }

Godot GDScript — 错误示例

# ❌ AI 常见错误:最基础的输入,无手感优化 extends CharacterBody2D var speed = 200.0 var jump_force = -400.0 func _physics_process(delta): # 错误 1:硬编码按键 var direction = 0 if Input.is_key_pressed(KEY_LEFT): direction = -1 if Input.is_key_pressed(KEY_RIGHT): direction = 1 # 错误 2:瞬间达到最大速度(无加速曲线) velocity.x = direction * speed # 错误 3:简单重力,无下落加速 if not is_on_floor(): velocity.y += 980 * delta # 错误 4:无 Coyote Time,无输入缓冲 if Input.is_key_pressed(KEY_SPACE) and is_on_floor(): velocity.y = jump_force move_and_slide()

Godot GDScript — 正确示例

# ✅ 正确:完整输入系统(Input Map + 缓冲 + Coyote Time + 加速曲线) extends CharacterBody2D @export_group("Movement") @export var max_speed: float = 200.0 @export var acceleration: float = 1200.0 @export var deceleration: float = 1000.0 @export var air_acceleration: float = 600.0 @export_group("Jump") @export var jump_force: float = 400.0 @export var jump_cut_multiplier: float = 0.4 @export var coyote_time: float = 0.12 @export var jump_buffer_time: float = 0.1 @export var fall_gravity_multiplier: float = 1.5 @export var base_gravity: float = 980.0 var _coyote_timer: float = 0.0 var _jump_buffer_timer: float = 0.0 var _is_jumping: bool = false func _unhandled_input(event: InputEvent) -> void: # 使用 Input Map Action(可在项目设置中重映射) if event.is_action_pressed("jump"): _jump_buffer_timer = jump_buffer_time # 短按跳跃:释放时削减上升速度 if event.is_action_released("jump") and _is_jumping and velocity.y < 0: velocity.y *= jump_cut_multiplier func _physics_process(delta: float) -> void: # 使用 Input Map 的 Action(自动支持键盘 + 手柄) var input_dir := Input.get_axis("move_left", "move_right") # 加速/减速曲线 var target_speed := input_dir * max_speed var accel_rate: float if is_on_floor(): accel_rate = acceleration if abs(input_dir) > 0.01 else deceleration else: accel_rate = air_acceleration velocity.x = move_toward(velocity.x, target_speed, accel_rate * delta) # 重力:下落时加速 if not is_on_floor(): var gravity_mult := fall_gravity_multiplier if velocity.y > 0 else 1.0 velocity.y += base_gravity * gravity_mult * delta _coyote_timer -= delta else: _coyote_timer = coyote_time _is_jumping = false # 跳跃缓冲计时 _jump_buffer_timer -= delta # 跳跃:Coyote Time + 输入缓冲 if _jump_buffer_timer > 0.0 and _coyote_timer > 0.0: velocity.y = -jump_force _jump_buffer_timer = 0.0 _coyote_timer = 0.0 _is_jumping = true move_and_slide()

6.3 输入处理 Steering 规则

## 输入处理 Steering 规则 ### 输入抽象层规则 - 所有输入必须通过引擎的 Action 系统: - Unity: Input System Package(InputAction) - Godot: Input Map(项目设置中配置) - Unreal: Enhanced Input System - 禁止硬编码 KeyCode/KEY_* 常量 - 必须支持至少两种输入设备(键鼠 + 手柄) ### 手感优化必选项 动作类/平台跳跃类游戏必须实现: 1. **输入缓冲**:3-6 帧窗口(0.05-0.1s),缓存玩家的提前输入 2. **Coyote Time**:离开平台后 0.08-0.15s 仍可跳跃 3. **可变跳跃高度**:短按 vs 长按产生不同跳跃高度 4. **加速/减速曲线**:移动不应瞬间达到最大速度 5. **下落加速**:下落时重力倍增,提升跳跃手感 ### 摇杆处理规则 - 必须实现死区(deadzone):内死区 0.1-0.2,外死区 0.9-0.95 - 死区内的输入值归零,死区外重新映射到 0-1 - 支持径向死区(radial deadzone)而非轴向死区 ### 输入检测位置规则 - 按键检测(GetButtonDown/is_action_pressed):Update/_process/_unhandled_input - 持续输入(GetAxis/get_axis):Update/_process 中读取,FixedUpdate/_physics_process 中应用 - 禁止在 FixedUpdate/_physics_process 中检测按键按下事件(可能丢失)

7. 反模式五:渲染未优化(Unoptimized Rendering)

7.1 问题概述

渲染优化是游戏性能的最大瓶颈,也是 AI 最难自动处理的领域。AI 生成的场景代码通常”看起来正确”——物体显示在正确的位置——但完全不考虑 GPU 性能预算。常见问题包括:过多的 draw call(每个物体单独渲染)、严重的 overdraw(透明物体层层叠加)、复杂的 shader(片元着色器中使用分支和循环)、未使用 LOD(远处物体和近处物体使用相同精度)。

问题严重程度: 🔴 致命(直接导致帧率不达标)

渲染性能预算参考:

平台目标帧率Draw Call 预算三角形预算纹理内存
高端 PC60-144fps2000-50002-10M4-8 GB
中端 PC60fps1000-20001-3M2-4 GB
移动端(高端)60fps100-300100-500K512MB-1GB
移动端(中端)30fps50-15050-200K256-512MB
WebGL30-60fps100-500100K-1M256MB-1GB
Switch30fps200-500200K-1M1-2 GB

7.2 AI 生成的问题代码

Unity C# — 错误示例

// ❌ AI 常见错误:不考虑渲染性能的场景生成 public class ForestGenerator : MonoBehaviour { public GameObject treePrefab; public GameObject grassPrefab; public Material[] treeMaterials; // 10 种不同材质 void Start() { // 错误 1:每棵树使用不同材质(无法合批,每棵树 = 1 draw call) for (int i = 0; i < 1000; i++) { Vector3 pos = new Vector3( Random.Range(-100f, 100f), 0, Random.Range(-100f, 100f)); GameObject tree = Instantiate(treePrefab, pos, Quaternion.identity); // 每棵树随机材质 → 1000 个 draw call! tree.GetComponent<Renderer>().material = treeMaterials[Random.Range(0, treeMaterials.Length)]; } // 错误 2:大量小物体单独实例化(草地) for (int i = 0; i < 10000; i++) { Vector3 pos = new Vector3( Random.Range(-100f, 100f), 0, Random.Range(-100f, 100f)); // 10000 个独立 GameObject = 10000 draw calls! Instantiate(grassPrefab, pos, Quaternion.identity); } // 错误 3:没有 LOD,没有遮挡剔除,没有视锥剔除 // 错误 4:没有静态合批标记 } } // ❌ AI 常见错误:低效的 Shader // 错误 5:片元着色器中使用 if-else 分支 Shader "Custom/BadShader" { SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag fixed4 frag(v2f i) : SV_Target { // 错误:GPU 分支导致 warp 分歧 if (i.uv.x > 0.5) { return tex2D(_MainTex, i.uv) * _Color1; } else { return tex2D(_MainTex, i.uv) * _Color2; } } ENDCG } } }

Unity C# — 正确示例

// ✅ 正确:GPU Instancing + LOD + 合批优化 public class ForestGenerator : MonoBehaviour { [SerializeField] private Mesh treeMesh; [SerializeField] private Material treeMaterial; // 单一材质,启用 GPU Instancing [SerializeField] private Mesh[] treeLODMeshes; // LOD 0/1/2 [SerializeField] private Mesh grassMesh; [SerializeField] private Material grassMaterial; private Matrix4x4[][] _treeMatrices; // 分批(每批最多 1023 个) private Matrix4x4[][] _grassMatrices; void Start() { GenerateTrees(); GenerateGrass(); } void GenerateTrees() { // 使用 GPU Instancing:1000 棵树 = 1 draw call! const int batchSize = 1023; // Unity GPU Instancing 单批上限 const int treeCount = 1000; int batchCount = Mathf.CeilToInt((float)treeCount / batchSize); _treeMatrices = new Matrix4x4[batchCount][]; for (int batch = 0; batch < batchCount; batch++) { int count = Mathf.Min(batchSize, treeCount - batch * batchSize); _treeMatrices[batch] = new Matrix4x4[count]; for (int i = 0; i < count; i++) { Vector3 pos = new Vector3( Random.Range(-100f, 100f), 0, Random.Range(-100f, 100f)); float scale = Random.Range(0.8f, 1.2f); float rotation = Random.Range(0f, 360f); _treeMatrices[batch][i] = Matrix4x4.TRS( pos, Quaternion.Euler(0, rotation, 0), Vector3.one * scale ); } } } void GenerateGrass() { // 草地使用 GPU Instancing(10000 根草 = ~10 draw calls) const int batchSize = 1023; const int grassCount = 10000; int batchCount = Mathf.CeilToInt((float)grassCount / batchSize); _grassMatrices = new Matrix4x4[batchCount][]; for (int batch = 0; batch < batchCount; batch++) { int count = Mathf.Min(batchSize, grassCount - batch * batchSize); _grassMatrices[batch] = new Matrix4x4[count]; for (int i = 0; i < count; i++) { Vector3 pos = new Vector3( Random.Range(-100f, 100f), 0, Random.Range(-100f, 100f)); _grassMatrices[batch][i] = Matrix4x4.TRS( pos, Quaternion.identity, Vector3.one * 0.5f); } } } void Update() { // GPU Instancing 渲染:极少的 draw call foreach (var batch in _treeMatrices) { Graphics.DrawMeshInstanced(treeMesh, 0, treeMaterial, batch); } foreach (var batch in _grassMatrices) { Graphics.DrawMeshInstanced(grassMesh, 0, grassMaterial, batch); } } } // ✅ 正确:无分支 Shader(使用 step/lerp 替代 if-else) Shader "Custom/GoodShader" { SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing // 启用 GPU Instancing fixed4 frag(v2f i) : SV_Target { fixed4 texColor = tex2D(_MainTex, i.uv); // 无分支:使用 step + lerp 替代 if-else float mask = step(0.5, i.uv.x); return texColor * lerp(_Color1, _Color2, mask); } ENDCG } } }

Godot GDScript — 错误示例

# ❌ AI 常见错误:大量独立节点,无合批 extends Node3D func _ready(): # 错误 1:1000 个独立 MeshInstance3D(1000 draw calls) for i in 1000: var mesh_instance = MeshInstance3D.new() mesh_instance.mesh = preload("res://tree.tres") # 错误 2:每个实例使用独立材质副本 var mat = preload("res://tree_material.tres").duplicate() mat.albedo_color = Color(randf(), randf(), randf()) mesh_instance.material_override = mat mesh_instance.position = Vector3( randf_range(-100, 100), 0, randf_range(-100, 100)) add_child(mesh_instance) # 错误 3:透明粒子过多(overdraw 爆炸) for i in 50: var particles = GPUParticles3D.new() particles.amount = 200 # 50 * 200 = 10000 透明粒子! particles.position = Vector3(randf_range(-50, 50), 5, randf_range(-50, 50)) add_child(particles)

Godot GDScript — 正确示例

# ✅ 正确:MultiMesh + LOD + 粒子优化 extends Node3D @export var tree_mesh: Mesh @export var tree_material: Material # 单一共享材质 @export var tree_count: int = 1000 func _ready(): _generate_trees() _setup_particles() func _generate_trees() -> void: # MultiMeshInstance3D:1000 棵树 = 1 draw call! var multi_mesh := MultiMesh.new() multi_mesh.transform_format = MultiMesh.TRANSFORM_3D multi_mesh.instance_count = tree_count multi_mesh.mesh = tree_mesh # 启用颜色变化(无需独立材质) multi_mesh.use_colors = true for i in tree_count: var pos := Vector3( randf_range(-100, 100), 0, randf_range(-100, 100)) var scale := randf_range(0.8, 1.2) var rotation := randf_range(0, TAU) var transform := Transform3D() transform = transform.rotated(Vector3.UP, rotation) transform = transform.scaled(Vector3.ONE * scale) transform.origin = pos multi_mesh.set_instance_transform(i, transform) # 颜色变化通过实例颜色实现(零额外 draw call) multi_mesh.set_instance_color(i, Color(randf_range(0.7, 1.0), randf_range(0.8, 1.0), randf_range(0.7, 1.0))) var mmi := MultiMeshInstance3D.new() mmi.multimesh = multi_mesh mmi.material_override = tree_material # 启用 LOD mmi.lod_bias = 1.0 mmi.visibility_range_end = 150.0 mmi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF add_child(mmi) func _setup_particles() -> void: # 合并粒子系统:用少量高效粒子替代大量低效粒子 var particles := GPUParticles3D.new() particles.amount = 500 # 单个系统,合理数量 particles.visibility_aabb = AABB(Vector3(-60, 0, -60), Vector3(120, 20, 120)) # 设置 LOD:远处减少粒子 particles.visibility_range_end = 80.0 add_child(particles)

Unreal C++ — 错误示例

// ❌ AI 常见错误:每个物体独立 Actor void AForestGenerator::BeginPlay() { Super::BeginPlay(); // 错误:1000 个独立 Actor(巨大的 CPU 开销 + draw call) for (int32 i = 0; i < 1000; i++) { FVector Location(FMath::RandRange(-10000.f, 10000.f), FMath::RandRange(-10000.f, 10000.f), 0.f); FActorSpawnParameters Params; GetWorld()->SpawnActor<ATreeActor>(TreeActorClass, Location, FRotator::ZeroRotator, Params); } }

Unreal C++ — 正确示例

// ✅ 正确:使用 Instanced Static Mesh / HISM void AForestGenerator::BeginPlay() { Super::BeginPlay(); // Hierarchical Instanced Static Mesh:自动 LOD + 遮挡剔除 UHierarchicalInstancedStaticMeshComponent* HISM = NewObject<UHierarchicalInstancedStaticMeshComponent>(this); HISM->SetStaticMesh(TreeMesh); HISM->SetMaterial(0, TreeMaterial); HISM->SetCullDistances(5000.f, 15000.f); // LOD 距离 HISM->RegisterComponent(); TArray<FTransform> Transforms; Transforms.Reserve(1000); for (int32 i = 0; i < 1000; i++) { FVector Location(FMath::RandRange(-10000.f, 10000.f), FMath::RandRange(-10000.f, 10000.f), 0.f); float Scale = FMath::RandRange(0.8f, 1.2f); float Rotation = FMath::RandRange(0.f, 360.f); FTransform Transform; Transform.SetLocation(Location); Transform.SetRotation(FQuat(FRotator(0.f, Rotation, 0.f))); Transform.SetScale3D(FVector(Scale)); Transforms.Add(Transform); } // 批量添加实例(比逐个添加高效得多) HISM->AddInstances(Transforms, false); }

7.3 渲染优化 Steering 规则

## 渲染优化 Steering 规则 ### Draw Call 控制规则 - 大量相同物体必须使用 Instancing: - Unity: GPU Instancing / Graphics.DrawMeshInstanced - Godot: MultiMeshInstance3D - Unreal: (H)ISM Component - 静态物体必须标记为静态(启用静态合批/烘焙) - 共享材质:相同外观的物体必须使用同一材质实例 - 禁止在运行时 .material(Unity)创建材质副本(使用 .sharedMaterial) ### Overdraw 控制规则 - 透明物体数量严格限制(移动端 < 20 层) - 粒子系统必须设置合理的 maxParticles 和可见范围 - UI 必须分层:静态背景层 + 动态内容层 - 全屏后处理效果在移动端最多 2-3 个 ### LOD 规则 - 所有 3D 模型必须配置 LOD(至少 3 级): - LOD0:近距离,完整细节 - LOD1:中距离,50% 三角形 - LOD2:远距离,10-25% 三角形 - 粒子系统必须设置可见范围(远处不渲染) - 阴影必须设置级联距离和分辨率 ### Shader 规则 - 片元着色器禁止 if-else 分支(使用 step/lerp/smoothstep) - 禁止在片元着色器中采样超过 4 张纹理(移动端) - 使用 shader_feature / multi_compile 替代运行时分支 - 移动端优先使用 half/fixed 精度而非 float

8. 引擎专用 Steering 规则

8.1 Unity C# 专用 Steering 规则

文件路径:.kiro/steering/unity-csharp.md

--- trigger: auto match: "**/*.cs" --- # Unity C# Steering 规则 ## 项目配置 - Unity 版本:6000.x LTS - 渲染管线:URP(Universal Render Pipeline) - 脚本后端:IL2CPP(发布构建) - .NET 版本:.NET Standard 2.1 / .NET 6+ - 输入系统:Input System Package(非旧版 Input Manager) ## MonoBehaviour 生命周期规则 - Awake():获取自身组件引用(GetComponent) - OnEnable():订阅事件、注册到管理器 - Start():获取外部引用、初始化状态 - Update():输入检测、UI 更新、非物理逻辑 - FixedUpdate():物理计算、刚体操作 - LateUpdate():摄像机跟随、最终位置调整 - OnDisable():取消事件订阅、从管理器注销 - OnDestroy():释放非托管资源、清理对象池 ## 禁止清单(Unity 特有) - 禁止 FindObjectOfType / FindObjectsOfType(使用注册表模式) - 禁止 SendMessage / BroadcastMessage(使用直接引用或事件) - 禁止 GameObject.Find(使用序列化引用或依赖注入) - 禁止 Resources.Load(使用 Addressables) - 禁止 .material 属性(使用 .sharedMaterial 或 MaterialPropertyBlock) - 禁止 Camera.main 每帧调用(缓存引用) - 禁止 CompareTag 以外的 tag 比较(.tag == "xxx" 产生 GC) - 禁止 foreach 遍历 Dictionary(使用 for + GetEnumerator 或直接遍历 Keys/Values) ## 推荐模式 - 对象池:UnityEngine.Pool.ObjectPool<T>(Unity 内置) - 事件系统:C# event / UnityEvent(简单场景)/ ScriptableObject 事件通道(解耦场景) - 单例:使用 ScriptableObject 单例模式(非 MonoBehaviour 单例) - 序列化:[SerializeField] private 替代 public 字段 - 协程替代:UniTask(零 GC 异步)或 Awaitable(Unity 6 内置) ## 性能分析命令 - 打开 Profiler:Window > Analysis > Profiler - 打开 Frame Debugger:Window > Analysis > Frame Debugger - 内存快照:Window > Analysis > Memory Profiler - GPU 分析:Profiler > GPU 模块 ## 测试规则 - 单元测试:使用 Unity Test Framework(NUnit) - 测试文件放在 Tests/EditMode/ 和 Tests/PlayMode/ - 禁止在测试中使用 MonoBehaviour(使用纯 C# 类测试逻辑) - PlayMode 测试用于集成测试(需要 Unity 生命周期的场景)

8.2 Godot GDScript 专用 Steering 规则

文件路径:.kiro/steering/godot-gdscript.md

--- trigger: auto match: "**/*.gd" --- # Godot GDScript Steering 规则 ## 项目配置 - Godot 版本:4.4+ - 渲染器:Forward+(PC)/ Mobile(移动端)/ Compatibility(WebGL) - GDScript 版本:2.0(静态类型推荐) - 物理引擎:Godot Physics / Jolt Physics ## 类型标注规则(强制) - 所有函数参数必须标注类型 - 所有返回值必须标注类型 - 所有成员变量必须标注类型 - 使用 @export 替代 export(GDScript 2.0 语法) - 使用 := 进行类型推断赋值 ```gdscript # ✅ 正确:完整类型标注 var _speed: float = 5.0 var _health: int = 100 var _enemies: Array[Enemy] = [] func take_damage(amount: int, source: Node3D) -> bool: _health -= amount return _health <= 0 # ❌ 错误:无类型标注 var speed = 5.0 var health = 100 func take_damage(amount, source): health -= amount return health <= 0

节点生命周期规则

  • _ready():初始化、获取子节点引用、连接信号
  • _enter_tree():注册到全局系统
  • _exit_tree():断开所有信号、停止 Tween、从全局系统注销
  • _process(delta):非物理逻辑、输入检测、UI 更新
  • _physics_process(delta):物理移动、碰撞响应
  • _unhandled_input(event):游戏输入处理(优先于 _input)

禁止清单(Godot 特有)

  • 禁止 get_node() 使用字符串路径访问远距离节点(使用 @export 或 group)
  • 禁止 $NodeName 访问可能不存在的节点(使用 get_node_or_null)
  • 禁止在 _process 中调用 get_tree().get_nodes_in_group()(缓存结果)
  • 禁止 preload() 大型资源在脚本顶部(使用 ResourceLoader.load_threaded_request)
  • 禁止 queue_free() 前不断开信号连接
  • 禁止使用 String 作为信号名(使用 &“signal_name” StringName)

推荐模式

  • 信号:自定义 signal 用于组件间通信
  • 自动加载:全局管理器使用 AutoLoad(项目设置)
  • 资源:使用 Resource 类存储配置数据(类似 Unity ScriptableObject)
  • 对象池:自定义 Array 池 + visible/process 控制
  • 场景切换:使用 ResourceLoader 异步加载 + 加载屏幕

性能分析

  • 内置 Profiler:Debugger > Profiler
  • 监视器:Debugger > Monitors(FPS、物理、内存)
  • 远程场景树:Debugger > Remote(运行时检查节点树)
### 8.3 Unreal C++ 专用 Steering 规则 文件路径:`.kiro/steering/unreal-cpp.md` ```markdown --- trigger: auto match: ["**/*.cpp", "**/*.h", "**/*.hpp"] --- # Unreal Engine C++ Steering 规则 ## 项目配置 - Unreal 版本:5.5+ - 构建系统:UnrealBuildTool - C++ 标准:C++20(UE5 默认) - 输入系统:Enhanced Input System - 网络:Unreal Replication System ## 内存管理规则(UE 特有) - 使用 UPROPERTY() 标记所有 UObject 指针(GC 追踪) - 非 UObject 使用 TSharedPtr / TWeakPtr / TUniquePtr - 禁止 raw new/delete 管理 UObject(使用 NewObject / CreateDefaultSubobject) - 使用 TWeakObjectPtr 引用可能被销毁的 Actor - 大型数组使用 TArray::Reserve 预分配 - 字符串操作使用 FName(哈希比较)而非 FString(字符比较) ## Actor 生命周期规则 - Constructor:创建组件(CreateDefaultSubobject),设置默认值 - BeginPlay():初始化运行时状态、绑定委托、获取外部引用 - Tick(DeltaTime):每帧逻辑(尽量少用,优先用 Timer) - EndPlay():清理委托绑定、停止 Timer、释放资源 - BeginDestroy():释放非 UObject 资源 ## 禁止清单(UE 特有) - 禁止在 Tick 中使用 FindObject / GetAllActorsOfClass(缓存或使用 Subsystem) - 禁止 FString 在热路径中拼接(使用 FName 或 FText::Format) - 禁止 SpawnActor 大量相同 Actor(使用 HISM / ISM) - 禁止在游戏线程外访问 UObject(使用 AsyncTask(ENamedThreads::GameThread, ...)) - 禁止忽略 UPROPERTY() 标记(导致 GC 提前回收) - 禁止在 Header 中 #include 大型头文件(使用前向声明) ## 推荐模式 - 组件模式:功能拆分到 UActorComponent / USceneComponent - 委托:FMulticastDelegate / Dynamic Delegate(蓝图兼容) - 数据驱动:UDataAsset / UDataTable 存储配置 - 对象池:自定义 TArray 池 + Activate/Deactivate - 异步加载:FStreamableManager::RequestAsyncLoad - Subsystem:UGameInstanceSubsystem / UWorldSubsystem(全局服务) ## 构建规则 - 开发构建:DebugGame Editor - 性能测试:Development(非 Debug,Debug 禁用优化) - 发布构建:Shipping(移除所有调试代码) - 使用 SHIPPING_BUILD 宏保护调试代码 ## 性能分析 - Unreal Insights:运行时性能追踪 - Stat 命令:stat fps / stat unit / stat scenerendering - GPU Visualizer:ProfileGPU 命令 - 内存:stat memory / memreport -full

9. 完整 Steering 规则提示词模板

9.1 AI 编码助手游戏开发 Prompt 模板

以下提示词模板用于在与 AI 编码助手交互时,确保生成的游戏代码符合最佳实践:

模板 1:新功能开发

你正在为一个 [Unity 6 / Godot 4.4 / Unreal 5.5] 项目编写代码。 ## 技术约束 - 目标帧率:[60fps] - 目标平台:[PC + Mobile] - 当前场景 draw call 预算:[剩余 200] ## 功能需求 [描述你需要的功能] ## 必须遵守的规则 1. 物理逻辑必须在 [FixedUpdate / _physics_process / Tick] 中 2. 禁止在每帧回调中分配堆内存(使用对象池/预分配) 3. 所有事件订阅必须有对应的取消订阅 4. 输入必须通过 [Input System / Input Map / Enhanced Input] 抽象层 5. 大量相同物体必须使用 [GPU Instancing / MultiMesh / HISM] ## 输出要求 - 提供完整代码,包含初始化和清理逻辑 - 标注哪些代码在热路径中 - 说明 draw call 和内存影响

模板 2:性能审查

请审查以下 [Unity C# / GDScript / UE C++] 游戏代码的性能问题: ```[语言] [粘贴代码]

审查清单

  1. 热路径中是否有堆内存分配?(new、字符串拼接、LINQ、Lambda)
  2. 物理逻辑是否在正确的回调函数中?
  3. 组件/节点引用是否已缓存?
  4. 事件/信号是否有完整的订阅-取消订阅配对?
  5. 是否有不必要的每帧计算?(可以用脏标记/节流替代)
  6. 渲染方面:draw call 是否可以通过合批/Instancing 减少?
  7. 是否有 GC 友好的替代方案?

输出格式

对每个问题:

  • 🔴 严重 / 🟡 警告 / 🟢 建议
  • 问题代码行号
  • 修复方案(含代码)
  • 预估性能影响
#### 模板 3:反模式修复

以下代码存在 [物理不稳定 / 内存泄漏 / 事件生命周期错误 / 输入问题 / 渲染性能] 问题。

[粘贴问题代码]

请修复这段代码,要求:

  1. 保持功能不变
  2. 修复所有性能和正确性问题
  3. 添加必要的清理逻辑
  4. 使用引擎推荐的最佳实践
  5. 在注释中标注每处修改的原因

引擎版本

[Unity 6 / Godot 4.4 / Unreal 5.5]

目标平台

[PC / Mobile / Console]

### 9.2 AI 代码审查自动化 Steering 规则 文件路径:`.kiro/steering/game-code-review.md` ```markdown --- trigger: manual --- # 游戏代码审查 Steering 规则 当审查游戏代码时,按以下优先级检查: ## P0 — 必须修复(阻塞合并) - [ ] 物理逻辑在错误的回调函数中(Update 而非 FixedUpdate) - [ ] 热路径中有堆内存分配(new、字符串拼接、LINQ) - [ ] 事件订阅没有对应的取消订阅 - [ ] 直接修改 Transform 移动物理对象 - [ ] 对象销毁前没有清理引用和回调 - [ ] 每帧调用 Find/GetNode 等搜索方法 ## P1 — 应该修复(下个迭代) - [ ] 缺少对象池(频繁创建/销毁的对象) - [ ] 缺少输入缓冲和 Coyote Time(动作游戏) - [ ] 大量相同物体未使用 Instancing - [ ] 缺少 LOD 配置 - [ ] UI 每帧更新(无脏标记) - [ ] 硬编码按键(未使用 Input Action) ## P2 — 建议优化(有空再做) - [ ] 使用平方距离替代 Distance() - [ ] 射线检测使用 NonAlloc 版本 - [ ] 协程 WaitForSeconds 未缓存 - [ ] 可以用 StringName 替代 String - [ ] 可以用 MaterialPropertyBlock 替代材质副本

10. 实战案例:用 AI 开发 2D 平台跳跃游戏的 Steering 规则实践

10.1 项目背景

项目:一款 2D 像素风平台跳跃游戏(类似 Celeste 风格) 引擎:Godot 4.4 团队:1 人独立开发者 + AI 编码助手(Claude Code + Kiro) 目标平台:PC + Nintendo Switch + Mobile 开发周期:8 周

10.2 Steering 规则配置

开发者在项目初期配置了以下 Steering 规则结构:

pixel-platformer/ ├── CLAUDE.md # 全局规则 ├── .kiro/ │ ├── steering/ │ │ ├── game-general.md # 通用游戏规则(always) │ │ ├── godot-gdscript.md # GDScript 规则(auto: *.gd) │ │ ├── physics-platformer.md # 平台跳跃物理规则(manual) │ │ └── mobile-optimization.md # 移动端优化规则(manual) │ └── specs/ │ └── player-controller/ │ ├── requirements.md │ ├── design.md │ └── tasks.md ├── project.godot └── ...

10.3 开发过程中遇到的问题与解决

问题 1:AI 生成的角色控制器物理抖动

AI 第一次生成的代码:

# AI 生成 v1 — 存在物理抖动 extends CharacterBody2D func _process(delta): # ← 错误:应该用 _physics_process var direction = Input.get_axis("move_left", "move_right") velocity.x = direction * 200.0 if not is_on_floor(): velocity.y += 980 * delta if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = -400 move_and_slide()

Steering 规则拦截后 AI 重新生成:

开发者在 physics-platformer.md 中添加了明确规则后,AI 自动修正:

# AI 生成 v2 — Steering 规则生效后 extends CharacterBody2D @export_group("Movement") @export var max_speed: float = 200.0 @export var acceleration: float = 1500.0 @export var friction: float = 1200.0 @export var air_friction: float = 600.0 @export_group("Jump") @export var jump_velocity: float = -400.0 @export var jump_cut_multiplier: float = 0.4 @export var coyote_time: float = 0.1 @export var jump_buffer_time: float = 0.08 @export var gravity: float = 980.0 @export var fall_gravity_multiplier: float = 1.6 @export var max_fall_speed: float = 600.0 var _coyote_timer: float = 0.0 var _jump_buffer_timer: float = 0.0 var _was_on_floor: bool = false func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("jump"): _jump_buffer_timer = jump_buffer_time if event.is_action_released("jump") and velocity.y < 0: velocity.y *= jump_cut_multiplier func _physics_process(delta: float) -> void: var was_on_floor := is_on_floor() # 重力(下落加速) if not is_on_floor(): var grav_mult := fall_gravity_multiplier if velocity.y > 0 else 1.0 velocity.y = minf(velocity.y + gravity * grav_mult * delta, max_fall_speed) # Coyote Time if was_on_floor and not is_on_floor() and velocity.y >= 0: _coyote_timer = coyote_time if is_on_floor(): _coyote_timer = coyote_time else: _coyote_timer -= delta # 跳跃缓冲 _jump_buffer_timer -= delta # 跳跃执行 if _jump_buffer_timer > 0 and _coyote_timer > 0: velocity.y = jump_velocity _jump_buffer_timer = 0.0 _coyote_timer = 0.0 # 水平移动(加速/减速曲线) var direction := Input.get_axis("move_left", "move_right") if abs(direction) > 0.01: var accel := acceleration if is_on_floor() else air_friction velocity.x = move_toward(velocity.x, direction * max_speed, accel * delta) else: var fric := friction if is_on_floor() else air_friction velocity.x = move_toward(velocity.x, 0.0, fric * delta) move_and_slide()

问题 2:敌人生成系统 GC 卡顿

AI 第一次生成的代码导致每 3-5 秒出现一次明显卡顿(GC spike)。

开发者使用 Godot Profiler 发现问题后,在 Steering 规则中添加了对象池要求。AI 重新生成了使用对象池的版本,GC 卡顿完全消除。

性能对比:

指标无 Steering 规则有 Steering 规则
平均帧率45fps(移动端)60fps(稳定)
GC 频率每 3-5 秒一次几乎无 GC
Draw Call850(50 个敌人)12(MultiMesh)
内存增长2MB/分钟稳定
输入延迟2-3 帧< 1 帧

问题 3:场景切换崩溃

AI 生成的场景切换代码没有清理事件订阅,导致切换到新场景后旧场景的回调仍在触发,引发空引用崩溃。

解决方案:在 Steering 规则中添加了”场景切换安全”章节,要求所有节点在 _exit_tree() 中断开信号连接。AI 后续生成的代码自动包含了完整的清理逻辑。

10.4 Steering 规则效果总结

维度无 Steering 规则有 Steering 规则改善幅度
AI 生成代码一次通过率~35%~80%+45%
物理相关 Bug12 个/周1-2 个/周-85%
性能问题8 个/周1 个/周-87%
崩溃/空引用5 个/周0-1 个/周-90%
代码审查修改量60% 需重写15% 需微调-75%
开发速度基准2.5x+150%

10.5 关键经验

  1. Steering 规则越具体越好:不要写”注意性能”,要写”禁止在 _process 中使用 new”
  2. 分层规则比单一大文件更有效:通用规则 + 引擎规则 + 功能规则的三层结构
  3. 包含代码示例:在 Steering 规则中直接给出正确和错误的代码对比
  4. 持续迭代:每次发现 AI 犯的新错误,立即添加到 Steering 规则中
  5. 平台差异规则单独管理:移动端优化规则作为 manual trigger,在需要时激活

11. 额外反模式补充

11.1 反模式六:状态管理混乱

AI 生成的游戏代码经常使用嵌套 if-else 管理游戏状态,导致代码难以维护和扩展。

错误示例(Unity C#)

// ❌ 嵌套 if-else 管理状态 void Update() { if (isAlive) { if (isAttacking) { if (isGrounded) { // 地面攻击逻辑 } else { // 空中攻击逻辑 } } else if (isDashing) { // 冲刺逻辑 } else { if (isGrounded) { // 地面移动 } else { // 空中移动 } } } else { // 死亡逻辑 } }

正确示例(Unity C# — 状态机)

// ✅ 使用状态机管理 public abstract class PlayerState { protected PlayerController Player; public virtual void Enter() { } public virtual void Exit() { } public virtual void Update() { } public virtual void FixedUpdate() { } public virtual PlayerState CheckTransitions() => null; } public class IdleState : PlayerState { public override void Update() { // 仅处理 Idle 状态的逻辑 } public override PlayerState CheckTransitions() { if (!Player.IsGrounded) return new FallState(); if (Player.MoveInput.sqrMagnitude > 0.01f) return new RunState(); if (Player.JumpRequested) return new JumpState(); return null; } } // PlayerController 中: void Update() { _currentState.Update(); var newState = _currentState.CheckTransitions(); if (newState != null) { _currentState.Exit(); _currentState = newState; _currentState.Enter(); } }

正确示例(Godot GDScript — 状态机)

# ✅ Godot 状态机模式 # state_machine.gd extends Node class_name StateMachine @export var initial_state: State var current_state: State func _ready() -> void: for child in get_children(): if child is State: child.state_machine = self if initial_state: initial_state.enter() current_state = initial_state func _unhandled_input(event: InputEvent) -> void: current_state.handle_input(event) func _process(delta: float) -> void: current_state.update(delta) func _physics_process(delta: float) -> void: current_state.physics_update(delta) func transition_to(target_state: State) -> void: current_state.exit() current_state = target_state current_state.enter() # state.gd extends Node class_name State var state_machine: StateMachine func enter() -> void: pass func exit() -> void: pass func handle_input(_event: InputEvent) -> void: pass func update(_delta: float) -> void: pass func physics_update(_delta: float) -> void: pass

11.2 反模式七:音频管理缺失

AI 几乎从不主动生成音频管理代码,导致音效重叠播放、音量不可控、音频资源未释放。

错误示例

// ❌ 每次播放都创建新的 AudioSource void OnCollisionEnter(Collision collision) { // 错误:可能同时播放 100 个相同音效 AudioSource.PlayClipAtPoint(hitSound, transform.position); }

正确示例

// ✅ 音频池 + 冷却时间 + 音量管理 public class SFXManager : MonoBehaviour { public static SFXManager Instance { get; private set; } [SerializeField] private int poolSize = 16; private AudioSource[] _pool; private int _poolIndex; private readonly Dictionary<AudioClip, float> _lastPlayTime = new(); private const float MIN_INTERVAL = 0.05f; // 同一音效最小间隔 void Awake() { Instance = this; _pool = new AudioSource[poolSize]; for (int i = 0; i < poolSize; i++) { var go = new GameObject($"SFX_{i}"); go.transform.SetParent(transform); _pool[i] = go.AddComponent<AudioSource>(); _pool[i].playOnAwake = false; } } public void Play(AudioClip clip, Vector3 position, float volume = 1f) { if (clip == null) return; // 冷却检查:防止同一音效重叠 if (_lastPlayTime.TryGetValue(clip, out float lastTime) && Time.unscaledTime - lastTime < MIN_INTERVAL) return; var source = _pool[_poolIndex]; _poolIndex = (_poolIndex + 1) % poolSize; source.transform.position = position; source.clip = clip; source.volume = volume; source.Play(); _lastPlayTime[clip] = Time.unscaledTime; } }

11.3 反模式八:缺少平台适配考虑

AI 生成的代码通常只在编辑器/PC 上测试通过,不考虑移动端和主机的差异。

常见平台适配问题:

问题PC 表现移动端表现解决方案
高分辨率纹理正常内存溢出崩溃纹理压缩 + 分辨率适配
复杂 Shader流畅严重掉帧移动端专用简化 Shader
大量粒子正常GPU 过载平台差异化粒子数量
后处理效果流畅帧率减半移动端禁用/简化后处理
物理精度正常浮点精度问题使用 fixed-point 或限制世界大小
输入方式键鼠触屏抽象输入层 + 虚拟摇杆

平台适配 Steering 规则片段:

## 平台适配规则 - 所有资源必须提供多分辨率版本(1x/2x/4x) - Shader 必须提供移动端变体(使用 shader_feature) - 粒子数量必须根据平台动态调整: - PC: 100% - Mobile High: 50% - Mobile Low: 25% - 后处理效果必须可按平台开关 - 世界坐标不超过 ±10000 单位(浮点精度)

避坑指南

❌ 常见错误

  1. 在 Update/_process 中写物理逻辑

    • 问题:帧率不同导致物理行为不一致,高帧率移动快、低帧率移动慢
    • 正确做法:物理逻辑放在 FixedUpdate/_physics_process 中,使用固定时间步
  2. 每帧分配堆内存(new、字符串拼接、LINQ)

    • 问题:频繁触发 GC,导致不可预测的帧率卡顿(5-50ms 暂停)
    • 正确做法:预分配集合、使用对象池、StringBuilder、避免 LINQ 热路径
  3. 订阅事件/信号但不取消订阅

    • 问题:对象销毁后回调仍触发,导致空引用崩溃或内存泄漏
    • 正确做法:OnEnable/OnDisable 配对订阅,OnDestroy/_exit_tree 中清理
  4. 硬编码按键,缺少输入缓冲和 Coyote Time

    • 问题:不支持手柄、无法重映射、动作游戏手感极差
    • 正确做法:使用 Input Action 抽象层,实现输入缓冲(0.1s)和 Coyote Time(0.12s)
  5. 大量相同物体使用独立 GameObject/Node

    • 问题:每个物体 = 1 draw call,1000 棵树 = 1000 draw calls
    • 正确做法:GPU Instancing / MultiMesh / HISM,1000 棵树 = 1-2 draw calls
  6. 直接修改 Transform 移动物理对象

    • 问题:绕过物理引擎,碰撞检测失效,物体穿透
    • 正确做法:通过 Rigidbody.MovePosition / velocity / CharacterBody.move_and_slide
  7. 每帧调用 Find/GetNode/GetComponent 搜索方法

    • 问题:遍历整个场景树/组件列表,性能极差
    • 正确做法:在 Awake/Start/_ready 中缓存引用,使用注册表模式
  8. 不配置 Steering 规则就让 AI 写游戏代码

    • 问题:AI 生成的代码功能正确但性能极差,需要大量返工
    • 正确做法:项目初期就配置完整的 Steering 规则,持续迭代更新
  9. Shader 中使用 if-else 分支

    • 问题:GPU warp 分歧导致性能下降,移动端尤其严重
    • 正确做法:使用 step/lerp/smoothstep 等数学函数替代分支
  10. 忽略平台差异,只在编辑器中测试

    • 问题:PC 上流畅的游戏在移动端可能完全不可玩
    • 正确做法:早期就在目标平台上测试,配置平台差异化规则

✅ 最佳实践

  1. 项目第一天就配置 Steering 规则:在写第一行游戏代码之前,先配置好 CLAUDE.md / .kiro/steering / .cursorrules
  2. 分层规则架构:通用规则(always)+ 引擎规则(auto)+ 功能规则(manual)三层结构
  3. 规则中包含代码示例:直接在 Steering 规则中给出正确和错误的代码对比,AI 理解更准确
  4. 持续迭代规则:每次发现 AI 犯的新错误,立即添加到 Steering 规则中
  5. 使用 Profiler 验证:不要相信”看起来流畅”,用引擎内置 Profiler 验证帧率、draw call、GC
  6. 对象池优先:任何需要频繁创建/销毁的对象(子弹、特效、敌人、UI 元素)都使用对象池
  7. 输入手感是核心:动作游戏的输入缓冲、Coyote Time、可变跳跃高度不是”锦上添花”,是”必须有”
  8. 早期多平台测试:不要等到最后才在目标平台上测试,每周至少在真机上跑一次
  9. 数据驱动配置:游戏参数(速度、跳跃力、重力)使用 ScriptableObject/Resource/DataTable,不硬编码
  10. 状态机管理复杂逻辑:角色状态、游戏流程、AI 行为都使用状态机,避免嵌套 if-else

相关资源与延伸阅读

游戏开发最佳实践

  1. Fix Your Timestep!  — Glenn Fiedler 的经典文章,详解游戏物理时间步的正确实现方式,是理解固定时间步 vs 可变时间步的必读资料
  2. Game Programming Patterns  — Robert Nystrom 的免费在线书籍,覆盖对象池、状态机、观察者等游戏开发核心设计模式
  3. Unity Performance Best Practices  — Unity 官方性能优化指南,覆盖脚本、渲染、内存、物理等各方面
  4. Godot Performance Documentation  — Godot 官方性能优化文档,包含 GDScript 优化、渲染优化、物理优化
  5. Unreal Engine Performance Guidelines  — Epic 官方性能指南,覆盖 CPU/GPU 优化、内存管理、Profiling 工具

AI 辅助游戏开发

  1. Cursor Rules Guide  — Cursor IDE 规则配置完整指南,适用于游戏开发项目的 AI 规则配置参考(2025)
  2. Vibe Coding Complete Guide  — Vibe Coding 完整指南,包含游戏开发场景下的 AI 编码最佳实践(2025)
  3. Game Design Patterns Complete Guide  — 游戏设计模式完整指南,包含 Unity C# 和 Godot GDScript 双引擎实现(2025)

输入系统与手感优化

  1. Game Input Systems Complete Guide  — 游戏输入系统完整指南,覆盖跨平台控制器支持、输入缓冲、无障碍功能(2025)
  2. Why Input Buffering Makes or Breaks Action Games  — 深入解析输入缓冲对动作游戏手感的关键影响(2025)

参考来源


📖 返回 总览与导航 | 上一节:AI生成游戏资产 | 下一节:AI辅助嵌入式开发概览

Last updated on