日期:2026-08-25
项目:Platformer → Room Transitions → Isometric
目标读者:已经有后端或 Web 开发经验,正在借助 AI 系统学习 Godot,希望能够理解节点职责、空间转换、场景生命周期和素材工程,而不只是看到 Demo 能运行的人。
1. 今天真正练习的不是三个地图
表面上,今天完成了横版平台、俯视房间切换和 Isometric 地图。真正贯穿三者的主题是:
一个游戏世界怎样定义空间、约束运动、管理内容生命周期,并把同一份状态投影成不同画面。
三个项目各自负责一个层次:
| 项目 |
表面功能 |
主要节点和 API |
真正新增的能力 |
| Platformer |
跑跳、斜坡、移动与踩落平台 |
CharacterBody2D、TileMapLayer、AnimatableBody2D、Area2D、AnimatedSprite2D、Camera2D |
用物理公式和碰撞结果控制实时角色运动 |
| Room Transitions |
双向门、NPC、房间切换、状态恢复 |
PackedScene.instantiate()、Node.add_child()、queue_free()、Autoload、Signal、Tween |
区分常驻运行时和可卸载内容,并安全地替换场景子树 |
| Isometric |
菱形地图、选格、寻路、高地、遮挡、小地图 |
Isometric TileSet、map_to_local()、local_to_map()、AStar3D、Y Sort、SubViewport |
让逻辑网格、屏幕投影、素材脚点和逻辑高度彼此分离 |
能力递进可以概括为:
flowchart LR
A[连续物理运动] --> B[世界碰撞与平台规则]
B --> C[场景内容的装载与卸载]
C --> D[跨场景状态所有权]
D --> E[逻辑网格与屏幕投影]
E --> F[高度、遮挡与寻路]
F --> G[同一模型的多种表现层]
昨天建立了 Node、Scene、Signal、Resource、状态机和数据驱动的基础;今天进一步回答了三个更接近真实游戏工程的问题:
- 角色为什么能以某种“手感”运动,而不只是修改位置?
- 一个房间销毁后,什么应该消失,什么必须继续存在?
- 当逻辑世界不是屏幕上的正方形网格时,怎样保持数据、导航和画面一致?
2. 先建立今天的总心智模型:世界不是一张图片
今天反复出现四个彼此不同的层次:
Model / Rule
角色参数、逻辑格、阻挡、房间 ID、Flag
↓
World Structure
Node Tree、TileMapLayer、RoomRoot、Objects
↓
Simulation
Physics、AStar、输入、Signal、生命周期
↓
Presentation
Sprite、Animation、Camera、HUD、SubViewport
它们不能因为最终都出现在屏幕上,就混成同一件事。
例如 Isometric 高地同时涉及:
Vector3i(x, y, z):逻辑表面。
AStar3D:哪些表面可以互相到达。
HighGround: TileMapLayer:高地的地砖表现。
-z × 33px:投影后的视觉高度。
z_index = z × 10:覆盖普通 Y Sort 的绘制优先级。
只把 Sprite 向上挪 33 像素,不会自动创建一个可寻路的高地;只给导航点增加 z=1,屏幕画面也不会自动抬升。逻辑和表现必须显式连接。

Platformer 最终画面。左侧是角色和 TileMap 地形,右侧遥测面板实时显示运动状态、速度、地面法线、碰撞对象和跳跃容错计时。
3.1 为什么使用 CharacterBody2D
Player 的根节点是 CharacterBody2D。它适合“由代码决定移动意图、由物理世界修正最终结果”的角色。
基本流程是:
读取输入
→ 计算期望 velocity
→ move_and_slide()
→ Godot 检测碰撞并修正位移
→ is_on_floor() / get_floor_normal() / SlideCollision
→ 下一物理帧继续计算
这里的 velocity 不是“这一帧一定移动到哪里”,而是角色希望采用的速度。move_and_slide() 才会结合 TileMap、平台和斜坡碰撞,决定实际位移。
这与直接写:
position += velocity * delta
完全不同。后者绕过 CharacterBody2D 的滑动、地面判断和碰撞响应,可能直接穿过墙体。
3.2 用跳高和时间推导重力
MovementProfile 没有让人同时手调“跳跃初速度、上升重力、跳高”三个互相牵制的数字,而是暴露更符合设计意图的字段:
jump_height = 120px
time_to_jump_apex = 0.34s
time_from_apex = 0.28s
然后由运动公式计算:
跳跃初速度 v = -(2h / t_up)
上升重力 g_up = 2h / t_up²
下落重力 g_down = 2h / t_down²
GDScript 对应:
func jump_velocity() -> float:
return -(2.0 * jump_height) / time_to_jump_apex
func rising_gravity() -> float:
return (2.0 * jump_height) / pow(time_to_jump_apex, 2)
Godot 的 Y 轴向下为正,因此向上跳跃的初速度是负数。
上下使用不同重力,意味着角色可以慢一些上升、快一些落下。这不是物理模拟必须如此,而是平台游戏手感的设计选择。
3.3 一次物理帧的顺序
Player 的 _physics_process() 顺序不是随意排列的:
记录移动前是否在地面
→ 读取输入
→ 更新 Coyote / Buffer 计时
→ 尝试起跳
→ 处理提前松键
→ 应用分段重力
→ 应用水平加减速
→ move_and_slide()
→ 读取本帧碰撞结果
→ 更新朝向与动画
→ 判断是否刚刚落地
需要注意一个时间关系:
is_on_floor() 在调用 move_and_slide() 前,描述上一物理步得到的接触状态。
move_and_slide() 完成后,再读取 is_on_floor(),才能知道本帧是否刚刚落地。
所以代码先保存:
var was_on_floor := is_on_floor()
移动后再比较:
if not was_on_floor and is_on_floor():
landed.emit(speed_before_move)
3.4 Coyote Time、Jump Buffer 与 Jump Cut
这三项都是输入意图和严格物理条件之间的缓冲。
Coyote Time
角色刚离开平台边缘后,保留约 0.12s 的起跳资格:
上一刻在地面
→ coyote_time_left = 0.12
→ 离地后逐帧递减
→ 计时未归零时仍可起跳
Jump Buffer
玩家在落地前约 0.12s 按下跳跃,输入不会丢失:
按下 Space
→ jump_buffer_left = 0.12
→ 稍后获得地面资格
→ 自动消费缓冲并起跳
Jump Cut
角色仍在上升时提前松开 Space:
velocity.y *= jump_cut_multiplier
负的上升速度绝对值被缩小,于是更早到达顶点,形成长按高跳、轻按低跳。
起跳条件因此不再只是 is_on_floor() and just_pressed,而是:
jump_buffer_left > 0
AND
coyote_time_left > 0
3.5 CharacterBody2D、AnimatableBody2D 与 Area2D
Platformer 把三种容易混淆的节点放在了同一关卡:
| 节点 |
项目中的对象 |
是否实体阻挡 |
谁决定运动 |
主要职责 |
CharacterBody2D |
Player |
是 |
Player 脚本设置 velocity 并调用 move_and_slide() |
可控角色运动和碰撞响应 |
AnimatableBody2D |
移动/踩落木板 |
是 |
平台脚本改变位置 |
给角色提供可运动的物理表面 |
Area2D |
Checkpoint、踩落触发器 |
否 |
不负责实体移动 |
检测进入、离开和重叠事件 |
Checkpoint 只表示“玩家进入过这个区域”,所以它使用 Area2D。真正的新出生点由常驻 World 保存:
Player 进入 Checkpoint Area
→ World.activate_checkpoint()
→ active_spawn_position 更新
→ Player 跌出 death_y
→ World 把 Player 放回 active_spawn_position
Player 不应该自己知道关卡里有几个检查点,也不应该决定世界的重生规则。
3.6 TileMapLayer 为什么同时负责画面和地形碰撞
地面、普通平台和斜坡属于大量重复的静态世界数据。它们使用带 Physics Layer 的 TileSet:
TileSet Atlas
├── 视觉区域
├── 碰撞多边形
└── Custom Data: surface_type
↓
TileMapLayer
├── 画出地形
├── 提供权威碰撞
└── 允许 Player 查询脚下语义
而移动木板、踩落木板有独立状态和行为,因此保留为 PlatformActor Scene。
判断标准不是“它们看起来都是平台”,而是:
这是大量静态世界数据,还是一个具有生命周期和行为的 Gameplay Object?
3.7 地面法线和斜坡
get_floor_normal() 返回的是接触面的法线。
平地大致是:
(0, -1)
斜坡法线会同时包含 X 和 Y 分量。CharacterBody2D 根据 up_direction 和接触夹角判断一块表面是否属于地面。
这说明斜坡不是贴图造成的视觉倾斜;TileSet Physics Layer 中实际存在不同形状的碰撞多边形。
3.8 AnimatedSprite2D 的职责
角色动画使用:
Player
└── Visuals
└── Sprite: AnimatedSprite2D
↓
SpriteFrames Resource
idle / run / rising / apex / falling
Player 脚本只选择动画名:
sprite.play(&"run")
帧顺序、FPS 和是否循环属于 SpriteFrames Resource,不应该由 _process() 手动计时切帧。
4. Room Transitions:场景切换本质是节点子树的生命周期管理

Room Transitions 刻意保留诊断型表现:HUD、Player 与对话系统常驻,房间地面、NPC 和右侧出口属于当前可卸载 Room。
4.1 为什么没有直接调用 change_scene_to_file()
如果每扇门都直接切换整个主场景:
Door
→ change_scene_to_file(next_room)
→ 当前根场景整体销毁
那么 Player、HUD、对话框和运行时状态也会跟着销毁。之后必须重新创建,并额外恢复所有状态。
当前项目采用常驻 Game:
Game(常驻)
├── RoomRoot
│ └── CurrentRoom(可替换)
├── Player(常驻)
└── Interface(常驻)
切换房间真正执行的是:
从 RoomRoot 移除旧房间
→ 实例化新房间
→ 加入 RoomRoot
→ 移动同一个 Player 实例
这让“内容场景的生命周期”和“游戏会话的生命周期”分开。
4.2 PackedScene 和运行时实例不是同一个对象
房间表保存的是:
Dictionary[StringName, PackedScene]
PackedScene 是可以重复实例化的场景模板。调用:
var next_room := packed_room.instantiate()
才得到运行时 Node Tree。
可以类比为:
PackedScene ≈ 类定义 / 组件模板
instantiate() ≈ new / 创建组件实例
add_child() ≈ 挂载到运行时树
queue_free() ≈ 延迟销毁实例
4.3 room_id + entry_id 是稳定地址
出口没有保存另一个房间中的绝对坐标,而是声明:
target_room_id = "ruins"
target_entry_id = "from_forest"
目标房间内部维护:
Entries
├── default: Marker2D
├── from_forest: Marker2D
└── from_north_gate: Marker2D
因此门知道“去哪里”,但目标房间自己决定入口的真实坐标。
这比把 Vector2(742, 318) 写进出口更稳定:房间布局变化时只需移动 Marker2D,不必修改所有引用它的门、存档和任务。
4.4 房间替换为什么要先验证后提交
错误流程:
销毁当前房间
→ 尝试加载目标
→ 才发现 room_id 或 entry_id 无效
→ 游戏只剩空 RoomRoot
当前流程:
查找 PackedScene
→ instantiate()
→ 验证类型
→ 验证 Entry 是否存在
→ 全部通过
→ 才 mount_room()
这和数据库事务、Tetris 的“候选—验证—提交”是同一种工程思想:
先证明新状态可以成立,再破坏旧状态。
4.5 一次房间切换的完整事件流
sequenceDiagram
participant P as Player
participant E as RoomExit / Area2D
participant R as TransitionRoom
participant G as Game
participant S as GameSession
P->>E: body_entered
E->>R: transition_requested(room_id, entry_id)
R->>G: exit_requested(room_id, entry_id)
G->>G: instantiate_valid_room()
G->>P: input_enabled = false
G->>G: fade_to(1.0)
G->>G: mount_room()
G->>P: 设置 Entry 位置并清空 velocity
G->>S: record_arrival()
G->>G: fade_to(0.0)
G->>P: input_enabled = true
这里故意让 Signal 逐层转发:
RoomExit 不知道 Game 在哪里。
TransitionRoom 负责聚合本房间的出口和交互事件。
Game 是唯一有权替换房间的对象。
4.6 为什么淡出时锁定输入
淡出和淡入由 Tween 驱动,是异步过程:
await fade_to(1.0)
mount_room(...)
await fade_to(0.0)
如果期间仍接受输入,可能出现:
- 玩家在黑屏中继续移动。
- 新房间刚挂载就离开入口。
- 连续触发另一个出口。
- 同时开始第二次切换。
因此同时使用:
transition_in_progress
player.input_enabled = false
RoomExit.armed = false
它们分别保护全局切换流程、角色输入和单个出口的重复触发。
4.7 Autoload 保存什么,不保存什么
GameSession 是 Autoload:
/root/GameSession
├── current_room_id
├── arrival_entry_id
└── flags
房间销毁时它不会销毁,所以 NPC 第二次实例化后仍能判断:
met_forest_guide 是否存在?
但 Autoload 不应该成为可以随意操纵所有节点的万能 Manager。当前它只保存跨房间会话数据,并发出数据变化 Signal;NPC 对话 UI 仍由 Game 管理。
4.8 @onready 的生命周期边界
@onready var exits = $Exits 只有节点进入 SceneTree、执行 _ready() 前才完成赋值。
但是房间预验证发生在:
instantiate()
→ 尚未 add_child()
→ 尚未进入 SceneTree
因此 has_entry() 不能依赖只在 _ready() 后才可用的缓存引用,而要显式读取:
get_node("Entries").has_node(entry_id)
这是 Scene 实例化和 SceneTree 生命周期之间很具体的边界。
5. Isometric:逻辑网格没有倾斜,倾斜的是投影

Isometric 最终画面。主世界由 Camera2D 观察,右侧 CanvasLayer 显示三种坐标空间,左下 SubViewport 根据共享逻辑数据绘制实时小地图。
5.1 Isometric 不是把地图整体旋转 45°
逻辑地图仍然是普通二维网格:
(0,0) (1,0) (2,0)
(0,1) (1,1) (2,1)
(0,2) (1,2) (2,2)
变化发生在 Grid 到屏幕坐标的投影。当前 Tile 逻辑脚印为 132×66:
screen_x = (x - y) × 66 + 66
screen_y = (x + y) × 33 + 33
因此:
+X → 屏幕右下 (+66, +33)
+Y → 屏幕左下 (-66, +33)
这里常说的“45°”是屏幕上的视觉描述,不是 Godot 存在一个只能设置为 45° 的游戏规则。2:1 Isometric 菱形边缘在屏幕坐标中的斜率由宽高比决定。
5.2 三个坐标空间
项目同时维护:
| 空间 |
示例 |
用途 |
| 离散逻辑表面 |
Vector3i(10, 12, 1) |
阻挡、AStar、目标、逻辑高度 |
| 连续 Grid |
Vector2(3.42, 7.0) |
小地图中的平滑移动、投影逆变换 |
| Map-local Pixel |
Vector2(-198, 363) |
Node2D 位置、Line2D、Sprite 脚点 |
正变换由 IsoGridMath.grid_to_world() 实现;逆变换是解同一组方程:
grid_x = centered_x / 132 + centered_y / 66
grid_y = centered_y / 66 - centered_x / 132
连续 Grid 适合数学和插值;真正选择离散菱形格时,使用 TileMapLayer 的:
local_to_map()
让 Godot 根据 TileSet 的 Isometric 几何判断鼠标落在哪个菱形内。
5.3 Camera 启用后,鼠标坐标为什么要再转换
鼠标事件最初处于 Viewport 坐标,但 TileMap 接收世界/局部坐标。Camera2D 启用后,两者不再相等:
Viewport Mouse Position
→ Canvas Transform 的逆变换
→ World Position
→ TileMapLayer.to_local()
→ local_to_map()
→ Cell
项目也可以直接使用 get_global_mouse_position() 取得 Camera 变换后的世界坐标。
如果仍把 event.position 直接传给 TileMap,角色在相机移动后会走向鼠标之外的错误格子。
5.4 图片尺寸、Tile 脚印与素材脚点
Kenney 地砖图片高度可以是 99px,但 TileSet 的逻辑尺寸是 132×66。
原因是图片还包含地块侧面:
顶部菱形:逻辑地面脚印
土壤侧面:视觉厚度
同样,Player 和建筑根节点放在“脚接触地面的位置”,Sprite 作为子节点向上偏移。
Y Sort 比较 Node2D 原点的 Y,不会自动读取 PNG Alpha 边界或人物脚底。
5.5 Y Sort 与遮挡淡化是两个系统
场景结构:
Objects(y_sort_enabled = true)
├── Player(原点是脚点)
└── RedBuilding(原点是建筑脚点)
├── Visual: Sprite2D
└── OcclusionArea: Area2D
普通前后关系由 Y Sort 决定:
Player.y < Building.y → Player 先绘制 → 建筑在前
Player.y > Building.y → Player 后绘制 → Player 在前
建筑淡化则由 OcclusionArea 检测 Player 的 VisibilityProbe:
Area 重叠
→ Tween 修改 Visual.modulate.a
职责分别是:
- Y Sort:谁先画、谁后画。
- Area2D:玩家是否进入特定遮挡区域。
- AStar 阻挡:建筑脚下的 Cell 是否可以通行。
三者看起来都与“建筑挡住玩家”有关,但不能合并成同一套数据。
5.6 为什么二维游戏使用了 AStar3D
初始地图只有一层,可以使用:
AStarGrid2D + Vector2i(x, y)
加入高地后,逻辑表面升级为:
Vector3i(x, y, z)
AStar3D 不是因为画面变成了 3D,而是它天然接受带三个分量的位置,并允许显式建立跨层连接。
当前规则是:
相同 z 的四方向相邻格 → 自动连接
不同 z → 默认不连接
坡道低端与高端 → 显式连接
所以角色不能从高地边缘直接“爬墙”,只能经过:
(9,12,0) ↔ (10,12,1)
5.7 高度、Y Sort 与 z_index
高地画面向上移动:
screen_position = iso_position - Vector2(0, z × 33)
但角色被视觉抬高后,它的屏幕 Y 反而变小。仅使用 Y Sort,可能把高地角色判断成“更靠后”。
因此逻辑高度需要覆盖普通 Y Sort:
z_index = current_surface.z * 10
这说明:
Y Sort 适合解决同一逻辑平面上的前后顺序;跨逻辑层级时需要额外的绘制规则。
5.8 Camera2D、CanvasLayer 与 SubViewport
这三个节点经常同时出现在屏幕系统中,但职责完全不同:
| 节点 |
当前项目中的作用 |
是否产生独立渲染目标 |
Camera2D |
决定主世界哪一部分映射到主 Viewport |
否 |
CanvasLayer |
让 HUD 独立于主世界 Camera,固定在屏幕上 |
否 |
SubViewport |
为小地图建立独立 Canvas 和渲染结果 |
是 |
节点结构:
Interface: CanvasLayer
└── Minimap: PanelContainer
└── SubViewportContainer
└── SubViewport
└── MinimapRenderer: Node2D
小地图没有用第二台 Camera 原样拍摄整个主世界,而是让 MinimapRenderer._draw() 根据共享逻辑数据绘制简化视图:
MAP_SIZE / HIGH_REGION / BLOCKED_CELLS / Ramp / Building
↓
MinimapRenderer._draw()
↓
地面、高地、阻挡、建筑、路径、目标、Player
这形成了同一模型的两种表现:
地图模型
├── 主世界:Tile、Sprite、遮挡、Camera
└── 小地图:颜色、线段和标记
Player 的连续 Grid 位置每帧同步;地图结构只初始化一次;路径和目标只在路线改变时更新。不同数据采用不同更新频率。
6. 三个项目放在一起后,节点职责更清楚了
6.1 物理节点选择
需要主动移动且碰撞响应?
→ CharacterBody2D
需要成为会运动的实体表面?
→ AnimatableBody2D
只需要检测进入、离开、重叠?
→ Area2D
大量静态地形由 Tile 数据描述?
→ TileMapLayer + TileSet Physics Layer
具体对应:
| 需求 |
节点 |
| Platformer Player |
CharacterBody2D |
| Room Player |
CharacterBody2D |
| Moving/Falling Platform |
AnimatableBody2D |
| Checkpoint / RoomExit / InteractionDetector |
Area2D |
| 横版地形与 Isometric 地面 |
TileMapLayer |
6.2 四种“位置”不是同一个值
今天至少出现了四类位置:
| 类型 |
示例 |
所有者 |
| 物理世界位置 |
Player.global_position |
Node2D / Physics |
| 房间语义入口 |
room_id + entry_id |
Room / GameSession |
| 离散网格位置 |
Vector3i(x,y,z) |
Navigation / Rule |
| 屏幕与 Viewport 位置 |
鼠标、Camera 画面、小地图像素 |
Presentation |
它们之间应该通过明确函数转换,不能靠“看起来差不多”直接传递。
6.3 三种生命周期
应用生命周期
└── GameSession / Autoload
运行关卡生命周期
└── Game / Player / Interface
内容生命周期
└── CurrentRoom / Props / 临时场景
对象应该放在哪一层,取决于它需要活多久,而不是它在节点树中看起来适合放哪里。
6.4 三种更新方式
| 更新方式 |
当前用途 |
特点 |
_physics_process(delta) |
Player 物理、平台运动、跌落检测 |
固定物理步,适合碰撞和运动 |
_process(delta) |
Isometric Actor 插值、小地图 Player 点 |
每个渲染帧,适合非物理表现 |
| Signal / 事件更新 |
房间切换、路径变化、Flag、Area 进入 |
状态变化时才执行 |
不是所有逻辑都应该放进 _process()。能由事件驱动的状态,不需要每帧轮询。
7. 真实素材不是“最后再美化”,而是工程输入
Platformer 和 Isometric 都开始使用真正素材,但素材类型决定处理方式。
7.1 Source 与 Runtime 分层
assets/source/
完整供应商包、AI 原图、审计基线
↓ 选择、检查、转换
assets/runtime/
游戏实际加载的最小集合
↓ 构建工具
TileSet / SpriteFrames / Scene
完整供应商包通过 .gdignore 避免 Godot 导入数百个不使用的文件;Runtime 目录只保留真正需要进入项目和导出的素材。
7.2 为什么角色动画不继续切生成式大图
AI 图适合:
- 背景氛围。
- 单张角色立绘。
- 概念草图。
- 对严格像素边界要求较低的素材。
逐帧角色动画要求:
- 每帧尺寸一致。
- 脚点稳定。
- 动作周期连续。
- Alpha 边界可预测。
- 切片位置精确。
Platformer 最终改用 SunnyLand 官方独立帧,而不是继续猜测 AI 大图中的帧边界。Isometric 当前只需要静态角色,因此 AI 透明 PNG 可以胜任。
关键不是坚持某一种素材来源,而是:
根据资产的技术契约选择来源,并对尺寸、Alpha、脚点、授权和运行效果进行验收。
7.3 TileSet 构建工具的价值
TileSet 包含 Atlas 坐标、Tile 尺寸、碰撞多边形、Custom Data 等结构化信息。用工具脚本生成派生 .tres,可以:
- 固定输入素材和 Atlas 坐标。
- 避免 Inspector 手工操作无法复现。
- 自动重建碰撞和元数据。
- 用测试检查生成结果。
所以资产管线也属于代码和测试范围,而不是仓库外的手工步骤。
8. 今天反复使用的工程模式
8.1 数据所有权必须单一
| 数据 |
权威所有者 |
| 横版运动参数 |
MovementProfile |
| 当前检查点 |
PlatformerWorld |
| 当前房间和跨房 Flag |
GameSession |
| 当前挂载房间 |
RoomTransitionGame |
| Isometric 表面与连接 |
IsoHeightNavigation |
| 主世界绘制 |
TileMap / Objects |
| 小地图绘制 |
IsometricMinimap |
表现层可以读取或订阅数据,但不应该偷偷建立第二份权威状态。
8.2 候选—验证—提交
昨天在 Tetris 旋转中出现,今天又用于房间切换:
构造候选状态
→ 验证是否合法
→ 验证通过才替换真实状态
后续背包交易、装备更换和存档加载都应该继续采用同一模式。
8.3 Scene 表达可复用对象,Resource 表达可复用数据
PlatformActor.tscn
→ 节点结构、碰撞、Trigger、Visuals
MovementProfile.tres
→ 速度、跳高、时间、容错参数
Scene 实例化后进入 SceneTree并拥有生命周期;Resource 通常被节点引用,保存配置和领域数据。
8.4 Signal 表达意图,所有者执行改变
Checkpoint body_entered
→ World 修改出生点
RoomExit transition_requested
→ Game 替换房间
Actor path_changed
→ 主场景和小地图更新路线表现
发出事件的对象不一定有权直接修改最终状态。
8.5 调试表现是理解系统的工具
Platformer HUD 暴露:
- 速度。
- 运动状态。
- 地面法线。
- Coyote 和 Jump Buffer 计时。
- Tile Cell 和 Surface Type。
Isometric HUD 暴露:
- 离散 Cell。
- 连续 Grid。
- Map-local Pixel。
- Z Level。
- Path Steps。
调试 UI 的价值不是好看,而是把不可见的运行状态变得可观察。学习项目尤其应该保留这种可解释性。
9. 自动测试与人工画面验收分别负责什么
9.1 自动测试适合验证
- 跳跃公式是否满足目标高度和时间。
- Coyote、Buffer、Jump Cut 是否按条件发生。
- TileSet 是否包含正确碰撞和 Custom Data。
- 所有平台高度是否在跳跃预算内。
- 房间与入口映射是否正确。
- 非法房间请求是否保留当前状态。
- Flag 是否跨房间恢复。
- Grid 正逆变换是否一致。
- AStar 路径是否绕开阻挡并经过坡道。
- 高地 Player 是否获得正确 z-index。
- SubViewport 小地图是否收到共享状态。
- 素材尺寸、Alpha 和来源记录是否符合契约。
9.2 人工运行适合判断
- 加速、减速、顶点停留和下落是否舒服。
- 平台高度理论可达,但实际是否容易跳上去。
- 斜坡有没有卡顿或视觉错位。
- 淡出节奏是否自然。
- 建筑遮挡淡化是否让玩家看得清。
- Character Sprite 的大小和脚点是否合适。
1152×648 Mac 窗口下 HUD 是否遮挡主要画面。
- 小地图信息是否能辨认。
可以概括为:
自动测试验证规则和契约,人负责判断手感、构图与可读性。
10. 我们主动跳过了什么
Action Arena 已经实践过攻击、生命、伤害、敌人状态、音效和命中反馈。继续加入只会扩大关卡,而不会显著增加平台物理知识。
10.2 Room Transitions 保持诊断型画面
这个短专项的独特价值是 Scene 生命周期、入口映射和跨房间状态。真实素材管线已经在 Platformer 中实践,因此没有再次花大量时间包装两间房。
10.3 Isometric 没有继续加入 NPC 和战斗
Isometric 已覆盖投影、选格、AStar3D、Y Sort、高度、遮挡、Camera 和小地图。继续加入 NPC 战斗会重复 Action Arena 与 Room Transitions 的系统。
10.4 没有把小地图扩展成完整导航产品
当前没有加入:
- 点击小地图移动。
- Camera 可见范围框。
- 敌人、任务和楼层筛选。
- 战争迷雾。
- 图标聚合。
这些都可以继续做,但已经超出 Isometric 项目的必要学习上限。
11. 今天怎样继续训练“驾驭 AI”
11.1 不接受只在代码里成立的功能
AI 完成节点和脚本后,还需要:
Godot 导入
→ Parse / Headless Tests
→ 主场景启动
→ 真实渲染截图
→ 人工查看布局和素材
例如小地图测试通过,只能说明状态连接成立;实际 1152×648 截图才能确认它没有和 Telemetry 重叠。
11.2 要求下一步和下下步
先知道后续两步,可以判断当前实现是否在建立必要接口,还是在没有目标的情况下扩建。
但提前知道后续,不等于提前实现后续。当前 Demo 达到独特学习目标后,应主动停止。
11.3 发现实现偏离预期时,回到原生节点
角色动画曾经只有代码绘制表现,没有 AnimatedSprite2D。问题不是“画面能不能动”,而是它绕开了要学习的 Godot 原生动画资源和编辑器工作流。
修正后的标准是:
需要学习动画资源
→ AnimatedSprite2D + SpriteFrames
需要学习时间线和 Gameplay 事件
→ AnimationPlayer
需要复杂混合
→ 后续 AnimationTree 专项
11.4 素材也要有契约和来源
不能只要求“换成真实贴图”,还应检查:
- 来源和许可证。
- 原图与 Runtime 文件分层。
- 尺寸、Alpha 和脚点。
- Tile 或动画帧的切割方式。
- 编辑器能否预览。
- 构建和导出时是否可用。
11.5 用问题审查架构,而不是逐行背代码
面对 AI 生成的 Godot 项目,优先回答:
- 根节点是谁?
- 哪些节点是常驻的,哪些会销毁?
- 权威状态保存在哪里?
- 谁只发出意图,谁真正提交修改?
- 坐标从哪个空间转换到哪个空间?
- 物理、导航和绘制各自负责什么?
- 哪些数据每帧更新,哪些只在事件发生时更新?
- 如何自动验证,如何人工验收?
12. 关键词词典
物理与运动
CharacterBody2D:由代码控制速度,并通过 move_and_slide() 接受碰撞约束的角色物理体。
AnimatableBody2D:适合移动平台等由脚本或动画改变位置、同时影响其他物理体的对象。
Area2D:检测重叠,不提供普通实体阻挡。
velocity:期望速度,不等于未经物理修正的最终位移。
Floor Normal:角色与地面接触面的法向量。
- One-way Collision:通常只从上方阻挡角色的平台碰撞。
- Coyote Time:离开平台后短暂保留的起跳资格。
- Jump Buffer:起跳条件满足前短暂保存的输入意图。
- Jump Cut:松开跳跃键时削减上升速度,形成可变跳高。
场景与生命周期
PackedScene:尚未实例化的可复用 Scene 资源。
instantiate():从 PackedScene 创建运行时节点树。
add_child():把节点加入 SceneTree,使其开始接收完整生命周期。
queue_free():在安全时机延迟销毁节点。
- Autoload:在项目启动时挂载到根节点、可跨普通场景存在的全局节点。
- Entry Marker:房间内部有语义名称的到达位置。
- Mount:把已经验证的内容节点挂载到指定父节点。
Tile 与空间
TileSet:Tile 的纹理区域、几何、碰撞和元数据定义。
TileMapLayer:使用 TileSet 在地图坐标上放置 Tile 的单层地图节点。
- Grid Space:规则使用的逻辑格坐标。
- Map-local Space:TileMapLayer 内部的二维像素坐标。
- Viewport Space:窗口或渲染目标中的屏幕坐标。
- Canvas Transform:世界 Canvas 与 Viewport 之间由 Camera 等产生的变换。
- Isometric Projection:将正交逻辑网格映射到菱形屏幕布局的投影。
- Foot Point:Sprite 对应地面接触位置的节点原点。
导航与绘制
AStar3D:在任意三维点图上寻找低成本路径;本项目用第三维表达逻辑高度。
- Y Sort:根据同一排序上下文中的 CanvasItem Y 位置决定绘制顺序。
z_index:显式调整 CanvasItem 绘制层级。
Camera2D:决定主 Canvas 的观察位置、缩放和边界。
CanvasLayer:为 HUD 建立不受主世界 Camera 影响的 Canvas 层。
SubViewport:独立渲染一棵子节点树并产生 ViewportTexture。
SubViewportContainer:在 Control UI 中显示 SubViewport 的渲染结果。
_draw():CanvasItem 的自定义绘制回调。
queue_redraw():请求 Godot 在后续绘制阶段再次调用 _draw()。
13. 读完后应该能够回答的问题
- 为什么 Platformer Player 应调用
move_and_slide(),而不是直接修改 position?
- 为什么跳跃参数使用高度和时间,而不是同时手调初速度与重力?
- Coyote Time 和 Jump Buffer 分别保存什么?
CharacterBody2D、AnimatableBody2D 和 Area2D 在三个项目中分别对应哪些对象?
- 为什么静态地形使用 TileMapLayer,而移动木板使用独立 Scene?
get_floor_normal() 怎样证明斜坡底层碰撞确实不同?
- 为什么 Player 和 HUD 不放在 CurrentRoom 里面?
PackedScene 和实例化后的 Room Node 有什么区别?
- 为什么房间切换必须先验证新房间,再卸载旧房间?
- 为什么用
room_id + entry_id,而不是在门上写目标坐标?
- Autoload 应该保存什么,为什么不应该控制所有节点?
- 未进入 SceneTree 的实例为什么不能依赖
@onready 字段?
- Isometric 地图的逻辑网格是否真的旋转了 45°?
- 离散 Cell、连续 Grid 和 Map-local Pixel 分别用在什么地方?
- Camera2D 启用后,为什么鼠标事件需要转换回世界坐标?
- 为什么 99px 高的地砖图片可以拥有 66px 高的逻辑 Tile 脚印?
- Y Sort、Occlusion Area 和 AStar 阻挡分别解决什么问题?
- 二维游戏为什么会选择 AStar3D?
- 为什么高地角色还需要显式 z-index?
- Camera2D、CanvasLayer 和 SubViewport 的底层职责有什么不同?
- 为什么小地图没有直接重复渲染主世界的所有 Sprite?
- 哪些状态应该每帧同步,哪些应该由 Signal 更新?
- 为什么真正素材也需要自动测试和授权记录?
- 自动测试与人工画面验收各自不能替代什么?
- 怎样判断一个教学 Demo 已经达到学习上限,应当停止扩建?
14. 下一步
下一项目进入:
07-inventory-shop/
它承担长期路线中的“背包 + 商店 + 装备”,重点第一次系统学习数据层:
ItemDefinition: Resource
→ Inventory Model
→ Inventory Slot / Stack Rule
→ Control UI
→ Drag and Drop
→ Shop Transaction
→ Equipment Rule
→ Serialization / Save
这里会继续复用今天的原则:
- Resource 定义共享物品数据。
- 背包模型是权威状态,UI 只是表现。
- 买卖采用候选—验证—提交,避免金币和物品只改了一边。
- 拖放事件表达用户意图,Inventory 决定操作是否合法。
- 存档加载先验证,再替换当前状态。
真正的新增目标不是画出一排物品格,而是让数据模型、UI、交易和持久化第一次形成完整闭环。