日期:2026-08-24
项目:Snake → Tetris → Action Arena
目标读者:准备借助 AI 入门游戏开发,但不满足于“代码能跑”,希望能理解、判断、修改和继续扩展的人。
1. 今天真正练习的不是三个小游戏
表面上,我们实现了贪吃蛇、俄罗斯方块和俯视角动作竞技场。真正的学习路线是:
- 先理解 Godot 如何启动、更新和连接节点。
- 再学习如何用数据表达游戏世界,而不是用画面节点代替数据。
- 再把规则、角色能力、状态、配置、AI 和表现拆成可以独立思考的系统。
- 最后学习怎样让 AI 生成的代码仍然可检查、可替换、可测试、可继续扩展。
三个项目分别承担了不同职责:
| 项目 |
表面玩法 |
核心学习领域 |
最重要的认知变化 |
| Snake |
网格移动、吃食物、增长 |
Godot 生命周期、Node、Scene、Signal、Timer、输入、绘制 |
画面只是数据的显示结果 |
| Tetris |
方块移动、旋转、锁定、消行 |
二维数组、坐标变换、候选状态、规则模型、随机算法 |
先验证候选结果,再提交状态修改 |
| Action Arena |
移动、攻击、敌人、受击、特效 |
组件、状态机、Resource、事件载荷、寻路、动画、音频、VFX |
Gameplay、AI、数据和表现应该解耦 |
这条路线可以概括为:
flowchart LR
A[Godot 运行模型] --> B[数据表达游戏状态]
B --> C[规则与表现分离]
C --> D[组件化与状态机]
D --> E[数据驱动与事件解耦]
E --> F[AI / 动画 / 音频 / VFX]
F --> G[能够审查和驾驭 AI 生成的项目]
2. 先建立 Godot 的总心智模型
2.1 Godot 没有传统意义上的 main()
Godot 的入口不是某个由我们手写的 main() 函数,而是:
project.godot
↓ run/main_scene
入口 .tscn 场景
↓ Godot 实例化节点树
各节点收到生命周期回调
常见回调包括:
_ready():节点已经进入 SceneTree,并且子节点已经准备完成。
_process(delta):每个渲染帧调用,适合 UI、普通动画和视觉时间。
_physics_process(delta):按固定物理频率调用,适合移动与物理逻辑。
_unhandled_input(event):接收尚未被 UI 等系统处理掉的输入事件。
_draw():自定义绘制函数;调用 queue_redraw() 后,Godot 会安排重新绘制。
开头的下划线不是“私有”的语法标记。对于 _ready()、_process() 等函数,它表示 Godot 约定的引擎回调;对于 _on_timer_timeout(),则只是常用命名风格,真正使它运行的是 Signal Connection。
例如:
Timer 发出 timeout
↓ 场景中的 connection
_on_move_timer_timeout()
把函数改名为 move_snake_now() 也可以,只要 Signal 连接同步修改。因此:
函数名帮助人理解,连接关系决定事件最终调用谁。
2.2 Scene、Node、Script 的关系
可以把 Godot 的核心关系理解为:
Scene(可保存、可复用的节点树)
└── Node(运行时对象)
├── 属性
├── 子节点
├── Signal
└── Script(附加行为)
- Node 是组成游戏的基本运行对象。
- Scene 是一棵可以保存为
.tscn、以后重复实例化的节点树。
- Script 是附着在节点上的行为,不等于节点本身。
- SceneTree 是当前正在运行的整棵节点树。
- Main Scene 是项目启动时首先实例化的场景。
2.3 与 Web/DOM/JS 的类比
这个类比非常有用,但不是完全等价:
| Godot |
Web 类比 |
共同点 |
需要注意的差异 |
project.godot |
应用配置、构建配置和入口配置 |
声明项目如何启动 |
它同时包含渲染、窗口、输入、物理等引擎配置 |
.tscn Scene |
HTML 模板或 React 组件树 |
描述结构与组合关系 |
Scene 可以包含物理体、音频、Timer、资源引用等游戏对象 |
| Node Tree |
DOM Tree |
都是父子层级结构 |
Node 不一定可见,也可能只是规则、计时器或音频对象 |
.gd Script |
JavaScript/TypeScript 模块 |
提供行为和事件处理 |
GDScript 深度集成引擎生命周期、场景树和资源系统 |
$Node/Child |
querySelector() 或 React ref |
获取已存在对象的引用 |
$ 使用 NodePath,不是 CSS Selector |
| Signal |
DOM Event、CustomEvent、EventEmitter |
发送方不必知道全部接收方 |
Signal Connection 可以保存在场景资源里 |
_ready() |
mounted、connectedCallback() |
结构准备完成后初始化 |
Godot 明确依赖 SceneTree 生命周期 |
_process(delta) |
requestAnimationFrame() |
每帧更新 |
delta 是上一帧经过的秒数 |
_physics_process(delta) |
固定步长模拟循环 |
稳定推进运动规则 |
浏览器没有完全对应的默认物理循环 |
| Timer |
setTimeout / setInterval |
时间到后发出事件 |
Godot Timer 是节点,可暂停、受 time scale 影响、进入场景树 |
| Group |
class、tag、语义标记 |
不依赖具体节点名称识别对象 |
Group 还可以用于批量调用场景树对象 |
@export |
Component props + 可视化配置面板 |
从外部注入配置 |
Godot Inspector 可以直接编辑资源和节点引用 |
.tres Resource |
JSON 配置对象、可序列化数据资产 |
数据与逻辑分离 |
Resource 有类型、Inspector、引用和资源缓存语义 |
PackedScene |
组件模板、构造器 |
尚未实例化的可复用模板 |
实例化后得到真正的节点树 |
instantiate() |
createElement()、组件 mount |
创建运行时实例 |
创建后还需进入 SceneTree 才接收完整生命周期 |
add_child() |
appendChild() |
加入树结构 |
同时影响处理、绘制、物理和销毁生命周期 |
queue_free() |
unmount、remove() |
从运行结构移除 |
Godot 延迟到安全时机销毁对象 |
| Tween |
Web Animations API、GSAP Tween |
插值改变属性 |
Godot Tween 可直接操作 Node/Resource 属性 |
| AnimationPlayer |
GSAP Timeline、CSS/WAAPI 时间线 |
多轨道、关键帧、事件点 |
可以在时间线上调用 Gameplay 方法 |
最重要的类比是:
.tscn 更像结构模板,.gd 更像行为模块;但 Godot 的节点不仅是 DOM 元素,也可能是物理、音频、导航、计时和纯逻辑对象。
2.4 常见文件与路径
| 后缀或路径 |
作用 |
project.godot |
项目配置,并通过 run/main_scene 指向入口场景 |
.tscn |
文本格式的 Scene,保存节点树和资源引用 |
.gd |
GDScript 源代码 |
.tres |
文本格式的 Resource 数据资产 |
.uid |
Godot 为资源生成的稳定身份,用于资源重命名或移动后的引用追踪 |
.import |
Godot 对图片、音频等外部资产的导入配置 |
.godot/ |
本机导入缓存和编辑器缓存,通常不提交 Git |
res:// |
当前 Godot 项目的资源根目录 |
并不是所有字符串都要加 res://:
- 加载项目文件时使用:
preload("res://scripts/game.gd")。
- 场景内查找子节点时使用 NodePath:
$StateMachine/Idle。
- 普通字符串、Signal 名称、Group 名称不需要
res://。
3. 项目目录:领域边界比文件数量更重要
省略 .uid、.import 和编辑器缓存后,仓库的主要结构是:
godot-learning-roadmap/
├── 01-snake/
│ ├── project.godot
│ ├── main.tscn
│ ├── scripts/
│ │ ├── main.gd
│ │ ├── snake_game.gd
│ │ └── grid_utils.gd
│ └── tests/
├── 02-tetris/
│ ├── project.godot
│ ├── main.tscn
│ ├── scripts/
│ │ ├── main.gd
│ │ ├── tetris_board.gd
│ │ ├── tetromino.gd
│ │ ├── tetromino_catalog.gd
│ │ └── tetromino_bag.gd
│ └── tests/
└── 03-action-arena/
├── assets/audio/
├── resources/attacks/
├── scenes/
│ ├── world.tscn
│ ├── player.tscn
│ ├── enemy.tscn
│ └── effects/hit_effect.tscn
└── scripts/
├── actors/
│ ├── player.gd
│ ├── enemy.gd
│ ├── player_states/
│ ├── enemy_states/
│ └── shared_states/
├── combat/
├── components/
├── data/
├── presentation/
├── state_machine/
└── world.gd
目录不是为了“看起来专业”,而是在表达领域:
actors/:角色拥有哪些能力。
states/:角色在什么条件下使用能力。
components/:可组合、职责单一的 Gameplay 功能。
data/ 和 resources/:配置结构与具体配置资产。
combat/:一次战斗事件的数据协议。
presentation/:镜头、音频、粒子、数字等反馈。
scenes/:上述对象怎样被装配起来。
AI 生成项目时,目录树是第一份架构文档。先读目录,再读事件流,最后才需要逐行读代码。
4. Snake:先理解运行循环与数据模型
4.1 固定 Tick 与逐帧表现
贪吃蛇不是每一帧移动一点,而是每隔固定时间移动一格:
sequenceDiagram
participant Input as 输入事件
participant Model as SnakeGame
participant Timer as MoveTimer
participant View as main.gd / _draw
Input->>Model: queue_direction(direction)
Timer->>Model: step()
Model-->>Timer: MOVED / ATE_FOOD / HIT_WALL / HIT_SELF
Timer->>View: 更新 UI、状态、VFX
View->>View: queue_redraw()
这里分成两种时间:
- Rule Tick:由
Timer.timeout 驱动,每次处理一格移动。
- Render Frame:由
_process(delta) 驱动,用于食物呼吸和吃到食物的扩散圆环。
这比“所有东西都放进 _process()”更容易保证规则稳定。
4.2 蛇身不是递归,而是有序序列
蛇身使用:
Array[Vector2i]
数组中的第一个元素是蛇头,后面的元素依次表示身体。普通移动的核心是:
计算新蛇头
→ push_front(new_head)
→ 如果没吃食物,pop_back()
所以之前的直觉“移除最后一个,然后加第一个”基本正确。准确说是:增加新的头部,并在普通移动时移除旧尾部。吃到食物时不移除尾部,因此长度增加。
没有使用递归,因为身体不是“节点包含下一个节点”的链式调用;它只是一次遍历即可绘制的有序坐标数组。
4.3 为什么没有只用 Map 保存身体
Map/Dictionary 或 Set 很适合回答:
某个格子是否已被占用?
但蛇还需要回答:
谁是头?
谁是尾?
身体顺序是什么?
下一步应该删除哪一格?
这些是有序序列问题,因此 Array 更自然。大型蛇可以使用混合结构:
Array / Deque:维护身体顺序
Set / Dictionary:O(1) 查询占用
当前棋盘很小,遍历成本可以忽略;为了学习清晰度,先使用单一 Array。
4.4 输入缓冲
如果玩家在两个 Tick 之间快速按“上、左”,只保存最后一个按键可能丢失意图。我们使用最多两个方向的队列:
渲染帧输入:上 → 左
规则 Tick 1:消费“上”
规则 Tick 2:消费“左”
禁止反向时比较的不是当前方向,而是“最后一个已经计划的方向”。这是实时输入系统常见的 Buffered Input。
4.5 模型与表现分离
SnakeGame 继承 RefCounted,不进入 SceneTree:
- 它保存蛇身、食物、方向、分数和碰撞规则。
- 它不知道 Label、Timer、颜色或屏幕坐标。
main.gd 负责输入、节点、UI、绘制和 VFX。
这是今天第一次建立清晰的 Model/View 边界。
Web 类比:
SnakeGame ≈ reducer / domain model
main.gd ≈ UI controller / component
_draw() ≈ 根据 state 渲染 UI
StepResult ≈ domain event / action result
4.6 测试学到了什么
Snake 的纯规则模型可以无头运行:
/Applications/Godot.app/Contents/MacOS/Godot \
--headless \
--path 01-snake \
--script res://tests/test_snake_game.gd
无头不是唯一执行方式,只是自动化最方便的方式。还可以:
- 把测试脚本挂到专门测试场景并从编辑器运行。
- 使用 GUT 等第三方测试插件。
- 直接运行普通游戏场景做人工交互验证。
无头测试的价值是速度快、退出码明确、适合 AI 每次改动后自动验证。
5. Tetris:用候选状态驾驭复杂规则
5.1 棋盘为什么用二维数组
俄罗斯方块的核心问题是占用关系:一个确定的 10 × 20 空间里,哪些格子是空的,哪些已锁定。
cells[y][x] = -1 空格
cells[y][x] = 0..6 某种方块颜色/类型
这是稠密、固定大小的二维空间,二维数组比 Dictionary 更直观:
- 容量只有 200 格。
- 消行天然按 row 操作。
- 绘制天然按 y/x 遍历。
- 不需要为稀疏存储增加复杂度。
5.2 局部坐标与棋盘坐标
Tetromino 不直接保存四个绝对棋盘坐标,而是保存:
四个 local_cells + 一个 position
换算关系:
board_cell = position + local_cell
Web/图形类比:一个 SVG Group 或 DOM 容器拥有整体 transform,子元素保存相对位置。移动整个方块只需修改 position,不需要分别修改四个格子。
5.3 候选—验证—提交
旋转和移动都遵守同一个重要模式:
flowchart LR
A[当前合法状态] --> B[计算候选状态]
B --> C{can_place?}
C -- 是 --> D[提交修改]
C -- 否 --> A
旋转不是先改再撤销,而是:
- 计算候选局部坐标
(x, y) → (-y, x)。
- 转换成棋盘坐标。
- 检查边界和固定格。
- 全部合法才
apply_shape()。
这个模式可以迁移到很多领域:
- 背包是否能放入物品。
- 技能是否有足够资源。
- 建筑是否能放在地图上。
- 网络服务器是否接受客户端操作。
- 数据库事务是否满足约束。
5.4 为什么有多个地方调用 _lock_active_piece()
锁定方块有两个不同触发入口:
Gravity / Soft Drop 无法继续向下
├──→ _lock_active_piece()
Hard Drop 到达落点 ─┘
它们触发原因不同,但锁定后的规则完全相同:写入棋盘、消行、计分、生成新方块、判断 Game Over。因此应该汇聚到同一个函数,避免复制两份规则。
这叫“多个入口,一个领域操作”,与 Web 中多个按钮最终调用同一个 service method 类似。
5.5 7-bag、Next、Ghost 与 Hard Drop
- 7-bag:七种方块各放一次,Fisher–Yates 洗牌,一袋用完再生成下一袋。
- Next Piece:提前从 bag 取出一个类型作为预览。
- Ghost Piece:逐格计算还能下降的距离,只绘制半透明候选位置。
- Hard Drop:复用同一个下降距离,移动并立即锁定。
Ghost 与 Hard Drop 共用规则非常重要:
视觉预测和真实执行必须来自同一个计算,否则 AI 很容易生成“影子在这里,实际落在另一格”的重复逻辑 Bug。
6. Action Arena:从规则脚本进入游戏架构

上图来自当前 Godot 项目的真实运行截帧。橙红折线是 NavigationAgent2D 的调试路径;角色头顶文字是当前 FSM 状态;右侧敌人身上的橙色点状爆发和伤害数字来自临时 HitEffect 场景。
6.1 最小战斗闭环
最早的闭环只有:
Player 移动
→ 开启 Hitbox
→ Enemy Hurtbox 收到命中
→ HealthComponent 扣血
→ 血量归零后 Enemy queue_free()
这里建立了三个可复用组件:
- Hitbox:攻击生效区域,负责发送伤害。
- Hurtbox:可受击区域,负责接收并转发命中。
- HealthComponent:管理生命值,发出
health_changed、damaged、died。
Hitbox 不应该直接写:
enemy.health -= 1
因为这会要求攻击者认识 Enemy 的具体结构。当前方案让 Hitbox 只寻找拥有 receive_hit() 的 Area,Hurtbox 再通过 Signal 转发。
6.2 Signal 是解耦,不只是回调写法
同一个 Hurtbox.hit_received 可以有多个监听者:
flowchart LR
A[Hurtbox.hit_received] --> B[HealthComponent.take_hit]
A --> C[Actor 请求命中反馈]
B --> D[扣血 / damaged / died]
C --> E[音效 / 震动 / Hit Stop / 粒子 / 伤害数字]
Gameplay 和 Presentation 收到同一个事实,但各自处理自己的领域。这类似 Web 中一次业务事件同时更新 Store、埋点和通知 UI。
6.3 有限状态机 FSM
Player 同时只能处于:
stateDiagram-v2
[*] --> Idle
Idle --> Move: 有方向输入
Move --> Idle: 方向归零
Idle --> Attack: 攻击输入且冷却完成
Move --> Attack: 攻击输入且冷却完成
Attack --> Idle: 动画结束
Idle --> Hurt: 收到攻击
Move --> Hurt: 收到攻击
Attack --> Hurt: 收到攻击,中断攻击
Hurt --> Idle: 硬直结束
Enemy 复用相同的 StateMachine 调度器,但使用不同策略:
Idle → Chase → Attack
↖ ↑ ↓
Hurt 恢复后根据目标决定
关键职责:
- StateMachine:保存唯一
current_state,调用 exit()/enter(),转发输入和物理帧。
- State:决定什么时候请求转换。
- Actor:提供移动、攻击、停止、受伤等能力。
- State 不直接替换
current_state,只发出 transition_requested。
Web 类比:
StateMachine ≈ reducer / XState machine
State Node ≈ 某个状态下允许的 action handler
Actor 能力 ≈ service / command methods
transition_requested ≈ dispatch(action)
状态机解决的是“当前谁有决策权”,不是把所有函数机械地拆成文件。
6.4 数据驱动:AttackData 与 .tres
AttackData 定义攻击配置结构:
damage
knockback_force
hitstun_duration
active_duration
cooldown_duration
reach
hitbox_size
basic_slash.tres 和 enemy_strike.tres 是两份具体数据:
AttackData.gd:定义 schema / 类型
basic_slash.tres:一条具体配置记录
player.tscn:把配置注入 Player
Player:消费配置,不硬编码每个数值
Web 类比:TypeScript interface/class 定义结构,JSON/数据库记录保存具体值,组件通过 props 或依赖注入获得配置。
注意:Resource 是持久配置,不等于运行时事件。
6.5 AttackData 与 HitData 为什么是两个对象
| 对象 |
生命周期 |
表达的问题 |
| AttackData |
长期保存、可复用、可在 Inspector 编辑 |
“这种攻击通常是什么参数?” |
| HitData |
每次命中临时创建 |
“这一次是谁从哪里打中了我?” |
HitData 携带:
- 本次伤害。
- 攻击来源位置。
- 击退力度。
- 硬直时间。
这种对象也叫 Event Payload、Message、DTO。Signal 不是只能发出“发生了”,还可以携带事件上下文。
6.6 碰撞 Layer 与 Mask
- Layer:我属于哪些类别,“我是谁”。
- Mask:我希望检测哪些类别,“我要找谁”。
本项目使用独立通道:
Player Hitbox → Enemy Hurtbox
Enemy Hitbox → Player Hurtbox
Player Body ↔ Arena Wall
Enemy Body ↔ Arena Wall
它更像一张允许通信的过滤矩阵,不是 CSS 的 z-index。视觉遮挡顺序和物理 Layer 是两件事。
6.7 敌人感知与语义身份
Enemy 的 PerceptionArea 通过 body_entered 和 body_exited 维护 target。Player 通过 player Group 表达身份,不依赖节点名必须叫 Player。
PerceptionArea 检测 Body
→ body.is_in_group("player")
→ 保存 target 引用
→ Idle 请求 Chase
Group 相当于语义标签。它比检查具体脚本类型或固定路径更松耦合。
6.8 寻路与物理碰撞不是同一件事
场景中的墙被两个系统描述:
NavigationPolygon 的空洞:告诉 AI 路线不能经过这里
StaticBody2D Collision:真的阻挡角色身体
NavigationAgent2D 的工作流程:
target_position = Player.global_position
→ get_next_path_position()
→ 朝下一个路径点移动
→ move_and_slide() 处理真实物理移动
导航回答“应该走哪条路线”,物理回答“此刻能不能穿过去”。如果只做导航没有墙体,玩家仍可能走穿;如果只有墙没有导航,敌人可能一直顶着墙追玩家。
6.9 受击、硬直、击退与无敌时间
命中后的完整流程:
sequenceDiagram
participant Hitbox
participant Hurtbox
participant Health as HealthComponent
participant FSM as StateMachine
participant Actor
Hitbox->>Hurtbox: receive_hit(HitData)
Hurtbox->>Health: hit_received(HitData)
Health->>Health: 扣除生命值
Health-->>FSM: damaged(HitData)
FSM->>Actor: 进入 Hurt
Actor->>Actor: 中断攻击、击退、开启无敌
Actor->>FSM: HurtTimer 到期
FSM->>Actor: 恢复 Idle 或 Chase
Actor->>Actor: InvincibilityTimer 到期后重新启用 Hurtbox
相关术语:
- Knockback:命中造成的位置推移。
- Hitstun:受击后不能行动的硬直时间。
- Invincibility Frames / I-frames:短暂无敌窗口。
- Interrupt:当前 Attack/Move/Chase 被 Hurt 强制打断。
- Recovery State:受击结束后返回哪个状态。
HurtTimer 与 InvincibilityTimer 分开,是因为“恢复行动”和“可以再次受击”不一定在同一时刻。
6.10 动画驱动战斗
早期攻击由 AttackTimer 直接控制 Hitbox。后来 Player 改为 AnimationPlayer 时间线:
0.00s ───── 0.15s ───── 0.25s ───────── 0.42s
前摇 有效命中帧 后摇结束
↑ ↑ ↑
open() close() animation_finished
AnimationPlayer 中包含:
- Value Track:改变 Weapon 的 rotation、scale。
- Keyframe:某个时间点的属性值。
- Call Method Track:在 0.15s/0.25s 调用 Hitbox 开关函数。
animation_finished Signal:整段动画结束,Attack State 才返回 Idle。
WeaponPivot 负责角色朝向,Weapon 自己播放挥砍。我们没有旋转 Player 根节点,因为根节点还包含碰撞体、状态机和移动坐标系。
这一步建立了 Animation-driven Gameplay:动画不只是装饰,而是战斗时间线的一部分。
6.11 命中反馈:Camera、Hit Stop 与 Audio
CombatFeedback 集中处理表现:
- Camera2D offset:150ms 轻微随机震动。
- Hit Stop:把
Engine.time_scale 暂时降到 0.08。
- Hit Audio:播放撞击声并轻微随机 pitch。
- Swing Audio:Player 开始挥砍时播放空间音效。
Hit Stop 的恢复 Timer 使用 ignore_time_scale=true,否则负责恢复时间的 Timer 也会被自己减速。
连续命中还引入了异步竞争问题。通过递增 serial,只允许最后一次命中的等待任务恢复 time scale:
Hit #1 → serial 1 ──等待──┐ 发现已过期,不恢复
Hit #2 → serial 2 ────────┴→ 最后恢复到 1.0
这与 Web 中避免旧请求覆盖新请求的 request id / cancellation token 很相似。
6.12 临时特效场景与生命周期
每次命中动态创建 hit_effect.tscn:
preload PackedScene
→ instantiate()
→ setup(damage)
→ add_child()
→ GPUParticles2D 播放火花
→ Tween 上浮、淡出伤害数字
→ tween.finished
→ queue_free()
这个对象拥有自己的出生、播放和死亡,不要求 World 保存引用并定期清理。
相关概念:
- GPUParticles2D:批量模拟粒子。
- ParticleProcessMaterial:速度、扩散角、重力、阻尼、缩放和颜色渐变。
one_shot:只播放一次。
explosiveness:粒子在开始时集中喷发。
- Tween:用插值在一段时间内改变属性。
- Transient Object:短生命周期对象。
7. 今天反复使用的架构模式
7.1 单一职责
一个对象应该有一个主要变化原因:
- HealthComponent 因生命规则变化而修改。
- Hitbox 因命中规则变化而修改。
- CombatFeedback 因表现风格变化而修改。
- AttackData 因数值设计变化而修改。
7.2 组合优于巨型脚本
Player 不是一个包含所有系统的 1000 行脚本,而是组合:
CharacterBody2D
+ Hitbox
+ Hurtbox
+ HealthComponent
+ StateMachine
+ AnimationPlayer
+ Timer
+ AudioStreamPlayer2D
组件化不是“文件越多越好”,而是让可独立变化、可复用、可替换的职责获得边界。
7.3 事件驱动
Signal 表达已经发生的事实或请求:
timeout
health_changed
damaged
died
transition_requested
attack_finished
animation_finished
hit_feedback_requested
命名通常使用过去式表示事实,例如 damaged、died;使用 requested 表示请求尚需权威对象批准。
7.4 数据驱动
把“1 点伤害、44 像素距离”写在攻击逻辑里叫硬编码;把它们放在 AttackData Resource 中,逻辑读取数据,叫数据驱动。
数据驱动的价值:
- AI 可以帮你生成新配置,而不复制逻辑。
- 设计调参不需要改代码。
- Player 和 Enemy 可以复用同一套组件。
- 更容易形成技能表、武器表、敌人配置表。
7.5 先构造候选,再验证,再提交
这一模式从 Tetris 延伸到 Action Arena:
- 旋转候选是否合法。
- 状态切换请求是否来自当前状态。
- Hitbox 是否已经命中过同一个目标。
- 是否处于冷却、死亡或无敌状态。
它是控制 AI 生成代码副作用的关键方式。
7.6 规则与表现分离
规则层:坐标、血量、状态、伤害、路径
表现层:颜色、动画、音效、震动、粒子、数字
表现可以很华丽,但不能成为扣血是否发生的唯一依据;规则可以被测试,但不需要加载所有视觉资产。
8. 我们主动跳过了什么
跳过不是遗漏,而是控制学习范围。
8.1 Tetris Hold Piece
计划用 Hold Piece 学习“每个活动方块只能交换一次”的状态约束,但我们判断这一概念已经容易理解,因此没有继续实现。
如果以后补做,需要状态:
held_piece_type
can_hold_current_piece
新方块出生时重置权限,当前方块已经 Hold 过就拒绝再次交换。
8.2 Tetris Wall Kick / SRS
当前旋转靠墙失败,不会尝试左右平移寻找合法位置。正式俄罗斯方块通常使用 Super Rotation System 和 Wall Kick 表。这里跳过是因为重点是候选旋转与验证,不是完整复刻竞技规则。
8.3 后续自动化测试
Snake 和 Tetris 建立了纯模型测试及无头执行方法。Action Arena 后半段以学习架构和表现为主,没有为每个状态和反馈系统继续增加永久测试。
这适合学习 Demo,但生产项目至少应继续测试:
- 状态转换是否合法。
- 致命攻击与普通受伤的事件顺序。
- 同一攻击不能重复命中同一 Hurtbox。
- Hit Stop 后 time scale 必须恢复。
- 临时特效必须销毁。
今天仍然使用了短暂 smoke test 验证运行时关键链路,只是不把它们保留成正式测试套件。
8.4 RVO 群体避让
我们研究并明确跳过了 RVO。
- NavigationAgent/A*:解决从 A 到 B 应该走哪条路。
- RVO/ORCA:解决附近多个移动 Agent 怎样选择彼此不冲突的速度。
- Physics Collision:解决物体最终能不能相互穿透。
RVO 不是物理碰撞。它是一种 Local Avoidance(局部避让)。
没有在当前项目实现的原因:
- 场上只有少量敌人,收益不明显。
- 动作游戏敌人过度互相礼让,可能显得犹豫、绕圈、不敢进攻。
- 近战敌人更适合使用 Attack Slot、Reservation 或简单分离力。
- RVO 最适合专门的几十个 Agent 交叉移动实验场景。
RVO 不是“没用”,而是特定群众移动问题的工具,不应该因为引擎提供就默认打开。
8.5 Attack Slot、AnimationTree、对象池和正式资产管线
这些都没有在今天实现:
- Attack Slot:控制多个近战敌人谁能进入攻击位置。
- AnimationTree:处理更复杂的动画状态混合和 BlendSpace。
- Object Pool:大量重复特效时复用对象,避免频繁实例化/销毁。
- Audio Bus:统一管理 SFX/Music/UI 音量、压缩和效果。
- Sprite/Skeletal Animation:真实美术资产的导入和动画管线。
- Save/Load、设置菜单、输入重映射、导出发布。
当前项目的目标是建立这些系统出现之前所需要的架构语言。
9. 怎样驾驭 AI 生成游戏,而不是被生成结果驾驭
9.1 每一步先问五个问题
- 这一阶段只增加了哪个新能力?
- 新增了哪些 Node、Scene、Resource、Signal?
- 谁拥有数据,谁有权修改数据?
- 事件从哪里发出,经过哪里,最终由谁处理?
- 如果要替换视觉、AI 或数值,需要修改哪些文件?
如果 AI 无法用一张目录树和一条事件流说明改动,通常说明边界还不清楚。
9.2 要求 AI 先给结构,再给代码
推荐顺序:
目标
→ 当前问题
→ 可选方案
→ 为什么选择其中一种
→ 将新增的目录/节点树
→ 事件流
→ 实现
→ 运行验证
→ 独立 commit
→ 下一步
这能防止 AI 一次生成大量互相耦合的脚本,而你只能“相信它”。
9.3 用小提交保存知识增量
今天的提交不是按“文件”划分,而是按能力划分,例如:
build component-based combat loop
introduce player state machine
drive attacks with custom resources
add state-driven enemy combat
add obstacle-aware navigation
add hit reactions and invincibility
drive player attacks with animation
add combat feedback and audio
add transient hit effects
小提交的价值:
- 可以比较某个概念加入前后的差异。
- AI 改坏时容易定位和回退。
- 每个 commit 本身就是学习章节。
- 避免“重构、功能、格式化、资源替换”全部混在一起。
9.4 让 AI 运行真实引擎验证
只检查 GDScript 文本不够。至少应分层验证:
git diff --check:格式和冲突标记。
- Godot Editor headless load:资源路径、场景语法、脚本解析。
- 运行若干帧:
_ready() 和场景装配是否报错。
- 针对关键事件写临时 smoke test:动画关键帧、time scale 恢复、对象销毁。
- 视觉功能使用正常渲染窗口截帧检查。
- 需要手感判断时由人实际操作。
AI 很适合验证“是否发生”,人仍需判断“是否好玩”。
9.5 要求解释名词,但把名词绑定到项目中的对象
只背定义很容易忘。更好的提问方式是:
不要只解释 Dependency Injection;指出当前项目中谁把什么注入给了谁。
不要只解释 Signal;画出本次命中的发送方和接收方。
不要只解释 Resource;比较 AttackData.tres 与 HitData 的生命周期。
9.6 防止 AI 过度架构
常见危险信号:
- 为只有一个调用者的简单函数创建五层抽象。
- 项目还没有第二种需求,就先建立庞大的通用框架。
- 用复杂设计模式替代清晰的数据结构。
- 因为引擎提供某功能,就自动加入 RVO、行为树、对象池等系统。
- 所有节点都通过全局 Singleton 通信。
判断标准不是“专业项目是否用过”,而是:
当前问题是否已经需要这层复杂度,它是否让下一次变化更容易?
9.7 一份可复用的 AI 协作提示词
请分阶段实现这个 Godot 功能,每阶段只引入一个主要新概念。
实现前说明:
1. 当前问题和目标;
2. 至少两种可行方案;
3. 选择方案及原因;
4. 将变化的目录树和节点树;
5. 新名词及其在本项目中的对应对象;
6. 完整事件流和数据所有权。
实现时:
- Gameplay、数据、AI 和表现尽量分离;
- 每个 gd 文件开头写一句文件职责;
- 不覆盖已有未提交修改;
- 使用 Godot 实际加载并做与风险相称的运行验证;
- 一个知识增量对应一个 Git commit。
实现后说明:
1. 改了什么;
2. 我应该先看哪些文件;
3. 在 Godot 编辑器中如何观察;
4. 哪些内容有意跳过;
5. 下一阶段是什么。
10. 关键词词典
Godot 基础
- Node:运行时基本对象。
- Scene:可保存和重复实例化的节点树。
- SceneTree:当前运行中的整棵节点树。
- Main Scene:项目入口场景。
- Lifecycle Callback:由引擎在特定阶段调用的函数。
- Signal:节点之间的一对多事件通信。
- Connection:Signal 与接收方法之间的连接关系。
- NodePath:在节点树中定位节点或属性的路径。
res://:项目资源根路径。
preload():脚本解析时加载依赖。
load():运行时加载依赖。
@onready:节点 ready 后再取得子节点引用。
@export:把属性暴露给 Inspector 和场景装配。
数据与对象
- Resource:可保存、复用、由 Inspector 编辑的数据对象。
.tres:文本 Resource 文件。
- RefCounted:通过引用计数管理生命周期、不必进入 SceneTree 的对象。
- PackedScene:尚未实例化的 Scene 资源。
- Instance:模板在运行时创建出的具体对象。
- Dependency Injection:由外部把依赖交给对象,而不是对象内部硬编码寻找或创建。
- Data-driven:逻辑读取数据配置,而不是把每个数值写死在流程代码中。
- Runtime Payload:某次事件临时携带的数据,例如 HitData。
时间、输入与状态
- Delta:上一帧经过的秒数。
- Fixed Tick:固定时间间隔推进规则。
- Timer:时间到期后发出 timeout 的节点。
- Input Action:对键盘、手柄等具体输入的语义抽象。
- Input Buffer:暂存尚未被规则 Tick 消费的输入。
- FSM:Finite State Machine,有限状态机。
- State Transition:从一个状态切换到另一个状态。
- Enter/Exit:状态取得或失去控制权时的生命周期。
- Cooldown:动作再次允许执行前的等待时间。
空间、物理与 AI
- Local Position:相对父节点的位置。
- Global Position:相对整个世界的位置。
- CharacterBody2D:由代码控制移动的 2D 角色物理体。
- Area2D:检测重叠和进入/离开事件的区域。
- Collision Layer:对象所属的物理类别。
- Collision Mask:对象希望检测的物理类别。
- Navigation Mesh/Polygon:描述 AI 可以行走的区域。
- NavigationAgent2D:查询目标路径和下一个路径点。
- Local Avoidance:避免附近移动对象的局部速度规划。
- RVO:Reciprocal Velocity Obstacles,互惠速度障碍。
- ORCA:基于速度约束的互惠避碰方法。
- Attack Slot:预约玩家周围的有限攻击位置。
战斗与表现
- Hitbox:能够造成命中的区域。
- Hurtbox:能够接收命中的区域。
- Knockback:击退。
- Hitstun:受击硬直。
- I-frames:无敌帧/无敌时间。
- Wind-up/Anticipation:攻击前摇。
- Active Frames:攻击有效帧。
- Recovery:攻击后摇。
- Animation Track:动画控制某个属性或方法的轨道。
- Keyframe:动画时间线上的关键值。
- Call Method Track:在动画指定时间调用方法的轨道。
- Hit Stop:命中瞬间的短暂停顿或极慢时间。
- Camera Shake:通过 Camera offset 等方式制造冲击反馈。
- AudioStreamPlayer2D:带世界位置的音频播放器。
- GPUParticles2D:GPU 驱动的 2D 粒子系统。
- Tween:随时间插值改变属性的轻量动画工具。
- VFX:Visual Effects,视觉特效。
- Transient Object:创建后短暂存在并自行销毁的对象。
算法与规则
- Grid:离散网格。
- Dense Array:适合固定、密集空间的数组表示。
- Candidate State:尚未提交的候选状态。
- Validate Before Commit:验证通过后才修改真实状态。
- Fisher–Yates Shuffle:均匀洗牌算法。
- 7-bag:每袋包含全部七种 Tetromino 的随机系统。
- Ghost Piece:显示方块预测落点的半透明影子。
- Hard Drop:立即移动到最终落点并锁定。
- Wall Kick:旋转碰壁时尝试位置修正。
11. 从这里继续时应该掌握的判断能力
读完这份总结后,不必记住每一行代码,但应该能够回答:
- 一个 Godot 项目从哪里启动?
- Scene、Node、Script 和 Resource 分别负责什么?
- 哪些数据必须进入 SceneTree,哪些可以只是 RefCounted 模型?
- Signal 的发送方、接收方和 Payload 分别是谁?
- 为什么规则数据不应该只存在于画面节点?
- 为什么移动或旋转应先验证候选结果?
- 为什么 Hitbox、Hurtbox、Health 不合并成 Player/Enemy 的特例代码?
- 状态机解决了什么控制权问题?
- 导航和物理碰撞为什么要同时存在?
- AttackData 和 HitData 为什么不能混为一谈?
- 动画如何精确控制攻击有效帧?
- Hit Stop 为什么必须使用不受 time scale 影响的恢复计时?
- 临时特效为什么要自行销毁?
- 哪些系统当前值得实现,哪些属于过早复杂化?
- AI 生成改动后,应该如何从结构、事件流和运行结果三层验证?
如果这些问题能够用自己的话解释,就已经不再只是“让 AI 帮我写一个游戏”,而是在使用 AI 驱动一个自己理解的游戏工程。
12. 下一步
下一次可以先做一次 Action Arena 复盘:不看实现,从空白画出目录树、状态图和完整命中事件流,再用实际代码核对。
之后第四个项目应该进入一个新的领域,而不是继续堆 Action Arena 功能。可选方向:
- 2D 平台跳跃:重力、地面检测、Coyote Time、Jump Buffer、Camera 跟随。
- 程序生成地牢:随机、图结构、连通性、TileMap 和种子复现。
- UI/背包专项:Control 布局、Drag & Drop、Resource 物品数据、存档。
- 3D 小型探索:从 Node2D/CharacterBody2D 过渡到 3D 坐标、相机和光照。
选择标准仍然是:下一个项目必须带来新的核心知识,而不是只增加更多已经理解的规则。