第5章 让主角动起来:玩家角色创建
「行是知之始,知是行之成。」——陶行知
第 4 章我们打好了地基:文件夹各就各位、主场景骨架立起、三个全局单例就位、第一份数据文件也登记入库。但此刻按下 F5,屏幕上还只有一个空荡荡的黑色世界——该让《酒魂》的主角登场了。
本章要解决什么问题: 创建主角场景
player.tscn,让她拥有八方向的待机与行走动画,能用键盘(WASD / 方向键)在画面上朝任意方向行走、停下时面向正确的方向——并且从第一天起就预留好存档接口。本章你将得到: 一个可被键盘完全控制的主角,以及两个日后所有角色(敌人、NPC)都会复用的核心套路:「精灵表切帧」和「身体 + 动画 + 移动」三件套,外加
to_dict()/from_dict()序列化约定。项目完成度到达 10%。
本章的学习顺序刻意安排成「先让她动,再让她撞」:先建场景、做动画(5.15.2),再加移动(5.3.15.3.3),最后才补碰撞体和物理层(5.3.4)。原因很简单——动画是看得见的成果,做出来立刻有反馈;碰撞是看不见的规则,而且本章还没有地图,碰撞暂时「无墙可撞」。先尝到甜头,再补规矩,学习曲线才平缓。
5.1 给主角一个「身体」:CharacterBody2D
角色首先得是一个「东西」:有位置、能碰撞、可移动。Godot 提供了三种 2D 物理身体,分工不同:
| 节点 | 谁说了算 | 适合谁 | 《酒魂》里的例子 |
|---|---|---|---|
StaticBody2D |
谁也不动,永远静止 | 地面、墙壁、家具 | 第 6 章的瓦片碰撞 |
RigidBody2D |
物理引擎说了算(受力、弹跳) | 弹球、掉落的物理道具 | 本书暂不用 |
CharacterBody2D |
你的代码说了算,引擎只帮你处理碰撞 | 玩家、敌人、NPC | 主角(本章)、敌人(第 11 章) |
为什么主角选
CharacterBody2D? 因为我们需要「指哪走哪」的精确控制:按 A 就向左、松手就停下——这是俯视角 RPG 的标准需求。RigidBody2D会被力推着滑来滑去,主角走路像在溜冰,显然不对。而碰撞检测(不能穿墙)这种脏活累活,CharacterBody2D帮我们干,我们只管写「往哪走」。
5.1.1 创建主角场景:身体、样子、眼睛
本章先搭「身体 + 样子 + 眼睛」三件套,碰撞体 5.3.4 再加:
- 新建场景(Ctrl+N),根节点选择 CharacterBody2D,重命名为
Player; - 选中
Player,添加子节点 AnimatedSprite2D——它负责显示动画,5.2 节配置; - 再给
Player添加子节点 Camera2D——它是「眼睛」,把它的 Position 设为(0, -64)。这一步只有三秒钟,但少了它,后面按 F6 你会看到一片空白(理由见下面的方框); - Ctrl+S 保存到
scenes/characters/文件夹,文件名player.tscn(全小写——沿用第 4 章 4.3.2 的约定:场景文件小写、节点名大驼峰)。
现在的场景树:
Player (CharacterBody2D) ← 主角本体,挂脚本
├── AnimatedSprite2D ← 样子:显示动画
└── Camera2D ← 眼睛:看着主角,Position = (0, -64)

为什么第 3 步就要装相机?——不装,运行起来就是一片空白。
这是 Godot 的一条基本事实:世界坐标的原点
(0, 0)在画面的左上角。 新场景里一切都摆在原点,于是:
- 没有相机时,画面显示的是「从世界
(0, 0)开始、往右下铺开 640×480 的那一块」;- 而主角节点默认也站在
(0, 0)——她整个人落在画面左上角的外面。按 F6 运行,屏幕上什么都没有;- 装上相机、位置设为
(0, -64)(从主角脚底往上抬半个身位),镜头中心就对准了主角,她便稳稳落在画面正中。相机不是「可选项」,它是 2D 游戏的第一块地基。 而且这台相机第 6 章还要接着用——那时我们给它加上「平滑跟随」和「地图边界锁定」(6.6 节)。一台相机管一辈子,先装上,再调优。
为什么现在不加 CollisionShape2D? 两个理由。其一,本章没有地图——第 6 章才有墙、有桌子,现在加碰撞体,她没东西可撞,你验证不了效果,只能「信代码没错」;其二,学习新概念最好的节奏是「先看见成果,再上约束」。等 5.3.3 她能满屏跑了,5.3.4 再花五分钟给她「实体化」,你立刻就能理解碰撞体存在的意义。这不是偷懒,是刻意的教学顺序——工程上它也完全成立:先跑通可见的部分,再补不可见的部分。
5.2 添加动画:先从待机开始
身体有了,让她「活」起来。本节先做站着不动的待机动画——它比行走简单,适合先把「精灵表切帧」这套方法跑通。
5.2.1 认识本章素材:两张 8×8 精灵表
主角的动画素材已经准备就绪(随书素材包的 sucai/ 文件夹),是两张精灵表:
| 文件 | 内容 | 尺寸 | 网格 | 单帧 |
|---|---|---|---|---|
idle.png |
待机动画表 | 1024 × 1024 | 8 列 × 8 行 | 128 × 128 |
walk.png |
行走动画表 | 1024 × 1024 | 8 列 × 8 行 | 128 × 128 |
两张表的排版规则完全相同:每一行是一个朝向,一行里的 8 列是这个朝向的 8 帧动画。行与方向的对应关系(8 个方向一应俱全):
| 行号 | 朝向 | 行号 | 朝向 |
|---|---|---|---|
| 0 | 右 | 4 | 右下 |
| 1 | 左 | 5 | 左下 |
| 2 | 下(面朝屏幕) | 6 | 右上 |
| 3 | 上(背对屏幕) | 7 | 左上 |
先把素材拷进项目(本章唯一一次手工搬文件):
- 在项目根目录(
res://)下新建assets/sprites/player/文件夹; - 把
idle.png、walk.png两个文件复制进去; - 在 Godot 的文件系统面板里确认能看到它们(导入设置沿用 4.4.1 的全局 Nearest,无需逐张改)。
为什么用「一张大表」而不是 128 张小图? 三个理由。① 引擎端省事:一次导入两个文件,而不是让文件系统面板被上百个小图刷屏;② 画师端省事:精灵表是行业通行交付形态,逐帧出图反而少见;③ 代码端省事:行的位置就是方向、列的位置就是帧序号,「第 4 行第 3 列」这种规律性的东西,最适合用循环批量处理——5.2.3 你会看到,十几行代码就把 16 个动画全部切出来。这背后还是第 4 章 4.6 的数据驱动思想:把规律交给数据(行号、列号),把重复劳动交给代码(循环)。
一句诚实的提醒: 这批原始表由 AI 渲染生成,色彩数偏高(数千色),与全书「15 色统一色板」的纪律尚有差距;另外各方向的脚线还没有逐帧对齐(详见 5.3.4 的注记)。这些都不影响本章跑通流程——清洗管线(量化到 32 色、统一基线)在附录 B,到时一键处理,文件名和网格都不变,本章写的代码一行都不用改。
5.2.2 弧度与八方向:先搞懂屏幕坐标系
要让代码「看方向选动画」,得先回答:代码怎么表示方向? 答案是 Vector2(二维向量),而要从向量算出「朝哪个方向」,要用 Vector2.angle()——它返回一个弧度。
弧度是数学里描述角度的方式:整圆 2π,半圆 π,直角 π/2。Godot 内置了常量 PI(3.14159...)和 TAU(2π)。angle() 的返回范围是 (-π, π]。
先记住一个反直觉的事实:屏幕坐标系里,y 轴是向下的。 数学课上「上」是正方向,屏幕上「下」才是正方向。所以八个方向对应的弧度是:
| 方向 | 弧度 | 直观理解 |
|---|---|---|
| 右 | 0 |
x 正方向,起点 |
| 右下 | PI/4 |
y 向下为正,所以「下」是 +90° |
| 下 | PI/2 |
|
| 左下 | 3*PI/4 |
|
| 左 | PI 或 -PI |
边界处,两个值等价 |
| 左上 | -3*PI/4 |
越往上,负得越多 |
| 上 | -PI/2 |
|
| 右上 | -PI/4 |
上 (-PI/2)
↑
左上 (-3PI/4) ↖ ↗ 右上 (-PI/4)
●
左下 (3PI/4) ↙ ↘ 右下 (PI/4)
↓
下 (PI/2)
左 (-PI / PI) ←—— 零点在圆心,右 (0) ——→
知道了角度,还差最后一步:角度落在哪个范围,就播哪个方向的动画? 我们把整圆均分成八块,每块 45°,每块的分界线在 22.5°(即 PI/8)的整数倍上——这样每块的正中心恰好对准一个理想方向。比如「右」覆盖 [-22.5°, +22.5°),「右下」覆盖 [22.5°, 67.5°)。
为什么分界线是 22.5° 而不是 0°、45°? 想象摇杆推到「右偏下一点」的位置(比如 30°):它离「右」(0°)差 30°,离「右下」(45°)只差 15°——应该播右下的动画。如果分界线画在 0° 和 45°,30° 就会被错判成「右」。分界线放在两个方向的正中间(±22.5°),任何角度都会被判给离它最近的方向——键盘八方向和将来的手柄摇杆(第 26 章)都适用。
5.2.3 用代码切帧:一次生成 16 个动画
传统做法是在编辑器里手动建 16 个动画、逐帧添加图片——本项目的动画是 16 个动画 × 8 帧 = 128 帧,手动添加既枯燥又容易点错行。我们改用更聪明的办法:写一个小函数,让代码自动把两张表「切」成 16 个动画。
先认识主角工具:AtlasTexture(图集纹理)。它不复制任何图像数据,只是「一张大图上的一个矩形视野」——告诉引擎「别看整张 1024×1024,只看从 (x, y) 开始的 128×128 这一小块」。切换成本几乎为零,内存也不翻倍。
选中 Player 节点,右键「附加脚本」,保存为 scripts/player.gd,填入:
extends CharacterBody2D
# player.gd —— 《酒魂》主角控制器
# 挂在 player.tscn 的根节点 Player 上
# 第 5 章:精灵表切帧 + 八方向待机动画;5.3 节补移动;5.4 节补存档接口
# ---------- 素材规格常量(与 5.2.1 的表一一对应) ----------
const FRAME_SIZE := 128 # 单帧边长(px)
const COLS := 8 # 每行帧数
# 行序表:方向名 → 精灵表的第几行(见 5.2.1)
const DIR_ROWS := {
"right": 0, "left": 1, "down": 2, "up": 3,
"down_right": 4, "down_left": 5, "up_right": 6, "up_left": 7,
}
const IDLE_SHEET: Texture2D = preload("res://assets/sprites/player/idle.png")
const WALK_SHEET: Texture2D = preload("res://assets/sprites/player/walk.png")
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
var last_direction: Vector2 = Vector2.DOWN # 最后的移动方向,决定停下时面向哪
func _ready() -> void:
_build_animations()
update_animation("idle") # 开局面向下方,播待机
# 把两张精灵表切成 16 个动画(idle_方向 × 8 + walk_方向 × 8),一次装进 sprite
func _build_animations() -> void:
var lib := SpriteFrames.new()
for dir_name in DIR_ROWS:
var row: int = DIR_ROWS[dir_name]
_add_sheet_anim(lib, "idle_" + dir_name, IDLE_SHEET, row, 5.0)
_add_sheet_anim(lib, "walk_" + dir_name, WALK_SHEET, row, 8.0)
sprite.sprite_frames = lib
# 从一张表的第 row 行切出 COLS 帧,组成一个名为 anim_name 的动画
func _add_sheet_anim(lib: SpriteFrames, anim_name: String, sheet: Texture2D, row: int, fps: float) -> void:
lib.add_animation(anim_name)
lib.set_animation_speed(anim_name, fps) # idle 是缓慢呼吸感,walk 步频更快
lib.set_animation_loop(anim_name, true) # 待机与行走都是循环动画
for col in COLS:
var piece := AtlasTexture.new()
piece.atlas = sheet # 大表本身
piece.region = Rect2(col * FRAME_SIZE, row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE)
lib.add_frame(anim_name, piece)
# 把任意方向向量换算成八方向名(right / down_right / ...)
# 分界线在 22.5°(PI/8)的整数倍上,保证任何角度都判给离它最近的方向
func direction_to_name(dir: Vector2) -> String:
var angle := dir.angle()
if angle >= -PI / 8 and angle < PI / 8:
return "right"
elif angle >= PI / 8 and angle < 3 * PI / 8:
return "down_right"
elif angle >= 3 * PI / 8 and angle < 5 * PI / 8:
return "down"
elif angle >= 5 * PI / 8 and angle < 7 * PI / 8:
return "down_left"
elif angle >= 7 * PI / 8 or angle < -7 * PI / 8:
return "left"
elif angle >= -7 * PI / 8 and angle < -5 * PI / 8:
return "up_left"
elif angle >= -5 * PI / 8 and angle < -3 * PI / 8:
return "up"
else:
return "up_right"
# 播放动画:state 是 "idle" 或 "walk",拼接方向后缀得到完整动画名
func update_animation(state: String) -> void:
var anim_name: String = state + "_" + direction_to_name(last_direction)
sprite.play(anim_name)
按 F6(单独运行当前场景)测试:画面正中就是她——面朝屏幕、缓缓呼吸的待机动画。暂时她只是站着——5.3 节让她走起来。
看到的是纯色背景?正常。 本章还没有地图,画面里除了主角什么都没有,背景是 Godot 的默认清屏色。别急,第 6 章铺上江南小镇的瓦片,画面立刻就有烟火气了。
如果还是什么都看不见,先回 5.1.1 第 3 步确认
Camera2D在不在——这是十之八九的原因;剩下的一成在 5.5 节的排查表里。
三个新知识点:
preload()vsload():preload在编译期就把资源读进来,路径写错会直接报错、跑都跑不起来——把「素材路径」这种本该固定的东西钉死在编译期,错误越早暴露越好。load()是运行期才读,适合路径要到运行时才知道的场合。本章用preload。@onready var sprite:$AnimatedSprite2D是「按路径找子节点」。但脚本刚挂上时子节点可能还没创建好,所以加@onready——它让这行赋值推迟到「场景就绪」那一刻才执行。以后凡是「脚本要长期引用的子节点」,都这么写。AtlasTexture是「视野」不是「副本」:128 帧共用两张大图,显存里始终只有 1024×1024 × 2 这么大。如果你用图像软件把表切成 128 个小文件再逐个导入,反而多了 128 次导入开销。「引用,而非复制」是性能优化的第一直觉。还有一笔账值得算:
_build_animations一共 8 行有效逻辑,替你干了 128 次「新建 AtlasTexture → 填区域 → 拖进动画」的手工活。以后要改帧率、换单帧尺寸、换表,只动FRAME_SIZE和两个fps参数——这就是数据驱动最朴素的样子。
5.3 添加行走动画和移动控制
5.3.1 行走动画:已经就位了
这是 5.2.3 立刻兑现的红利:_build_animations() 当时就把 walk_right 到 walk_up_left 共 8 个行走动画一起生成好了(walk.png 与 idle.png 行序完全一致,所以同一个 DIR_ROWS 通吃两张表)。本节对动画零工作量,直接进入「怎么让她走」。
如果你在 5.2.3 之前跳过了什么,此刻的表现是:行走时角色原地滑动、或者报「动画不存在」。回到 5.2.3 检查
_build_animations是否被_ready调用即可。
5.3.2 配置输入映射
「按 WASD 向对应方向走」这层关系,用 Godot 的输入映射来定义——把物理按键抽象成「动作名」,代码只认动作名不认具体按键。
- 打开「项目 → 项目设置 → 输入映射」页签;
- 在顶部输入框添加四个动作:
left、right、up、down; - 点击每个动作右侧的「+」,为它们绑定按键(一个动作可以绑多个键):
| 动作名 | 绑定按键 |
|---|---|
left |
A、← |
right |
D、→ |
up |
W、↑ |
down |
S、↓ |

两个为什么:
- 为什么不用内置的
ui_left? 那是给菜单界面(焦点移动)准备的动作,而且默认绑了手柄十字键——游戏手柄一插,菜单行为和走路行为会互相搅和。游戏角色的动作单独建一套,各管各的。- 为什么下一节要用
Input.get_vector()而不是按四次键自己算? 它一次给你干了两件新手迟早要踩坑的事:① 归一化——同时按 W 和 D 时,原始向量长度是 √2,直接乘速度会斜着走得比直走快 41%(很多老游戏都有这个 bug);get_vector会把向量缩回长度 1,斜走直走一样快。② 死区过滤——将来接手柄摇杆时,摇杆在中间位置会有微小漂移,它自动忽略。一行代码,两个大坑提前填平。
5.3.3 移动控制代码:最终版 player.gd
在 5.2.3 的基础上做两处修改——① 顶部加移动速度;② 加 _physics_process。direction_to_name、update_animation、_build_animations 原样不动(5.2.3 拆分函数的设计红利:加移动只碰「移动」的部分)。修改完的完整脚本如下:
extends CharacterBody2D
# player.gd —— 《酒魂》主角控制器
# 挂在 player.tscn 的根节点 Player 上
# 第 5 章:精灵表切帧 + 八方向移动/动画 + 存档接口(5.4)
# ---------- 素材规格常量(与 5.2.1 的表一一对应) ----------
const FRAME_SIZE := 128 # 单帧边长(px)
const COLS := 8 # 每行帧数
# 行序表:方向名 → 精灵表的第几行(见 5.2.1)
const DIR_ROWS := {
"right": 0, "left": 1, "down": 2, "up": 3,
"down_right": 4, "down_left": 5, "up_right": 6, "up_left": 7,
}
const IDLE_SHEET: Texture2D = preload("res://assets/sprites/player/idle.png")
const WALK_SHEET: Texture2D = preload("res://assets/sprites/player/walk.png")
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
# 移动速度 px/s。出处:附录 E.1.2;128px 大角色下 200 偏慢,
# 从 280 起调,实机手感在 260~320 之间微调(在检查器里改,不用动代码)
@export var move_speed: float = 280.0
var last_direction: Vector2 = Vector2.DOWN # 最后的移动方向,决定停下时面向哪
func _ready() -> void:
_build_animations()
update_animation("idle") # 开局面向下方,播待机
func _physics_process(_delta: float) -> void:
# delta 参数本章用不到(move_and_slide 内部已按物理帧计算位移),
# 参数名前加下划线表示「有意不使用」,消除编辑器的警告
var direction := Input.get_vector("left", "right", "up", "down")
velocity = direction * move_speed # velocity 是 CharacterBody2D 自带的属性
move_and_slide() # 按当前 velocity 移动,并处理碰撞滑行
if direction != Vector2.ZERO:
last_direction = direction # 记住朝向,停下时要用
update_animation("walk")
else:
update_animation("idle")
# 把两张精灵表切成 16 个动画,一次装进 sprite(原理见 5.2.3)
func _build_animations() -> void:
var lib := SpriteFrames.new()
for dir_name in DIR_ROWS:
var row: int = DIR_ROWS[dir_name]
_add_sheet_anim(lib, "idle_" + dir_name, IDLE_SHEET, row, 5.0)
_add_sheet_anim(lib, "walk_" + dir_name, WALK_SHEET, row, 8.0)
sprite.sprite_frames = lib
# 从一张表的第 row 行切出 COLS 帧,组成一个名为 anim_name 的动画
func _add_sheet_anim(lib: SpriteFrames, anim_name: String, sheet: Texture2D, row: int, fps: float) -> void:
lib.add_animation(anim_name)
lib.set_animation_speed(anim_name, fps)
lib.set_animation_loop(anim_name, true)
for col in COLS:
var piece := AtlasTexture.new()
piece.atlas = sheet
piece.region = Rect2(col * FRAME_SIZE, row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE)
lib.add_frame(anim_name, piece)
# 把任意方向向量换算成八方向名(right / down_right / ...)
# 分界线在 22.5°(PI/8)的整数倍上,保证任何角度都判给离它最近的方向
func direction_to_name(dir: Vector2) -> String:
var angle := dir.angle()
if angle >= -PI / 8 and angle < PI / 8:
return "right"
elif angle >= PI / 8 and angle < 3 * PI / 8:
return "down_right"
elif angle >= 3 * PI / 8 and angle < 5 * PI / 8:
return "down"
elif angle >= 5 * PI / 8 and angle < 7 * PI / 8:
return "down_left"
elif angle >= 7 * PI / 8 or angle < -7 * PI / 8:
return "left"
elif angle >= -7 * PI / 8 and angle < -5 * PI / 8:
return "up_left"
elif angle >= -5 * PI / 8 and angle < -3 * PI / 8:
return "up"
else:
return "up_right"
# 播放动画:state 是 "idle" 或 "walk",拼接方向后缀得到完整动画名
func update_animation(state: String) -> void:
var anim_name: String = state + "_" + direction_to_name(last_direction)
sprite.play(anim_name)
三个「为什么」:
- 为什么写在
_physics_process而不是_process?_process每渲染帧调用一次,帧率忽高忽低;_physics_process按固定物理频率(默认 60 次/秒)调用,移动和碰撞计算必须放在这里,否则同一台电脑不同帧率下走路速度会不一样。记住口诀:「动身体的代码进物理帧,动界面的代码进渲染帧」。velocity从哪来的? 它是CharacterBody2D内置的属性,不用声明。我们把「这一帧想怎么动」写进它,再调move_and_slide(),引擎就负责挪动节点——「我想怎么动」和「世界允许我怎么动」由这两个角色分工完成(后者在 5.3.4 加上碰撞体后才真正生效)。- 为什么停下时播 idle 也走
last_direction? 松开按键的瞬间direction变成零向量,没法换算方向;而last_direction记着最后一次移动的方向,角色就停得「面朝来路」,不会突然扭头。
进阶·可选:三行替代三十行 上面
direction_to_name的八分支判断胜在直白,但如果你学过一点数学,可以把它压缩成查表:把角度除以 45°(PI/4)后四舍五入,得到 0~7 的编号,直接查名字表——const DIR_NAMES := ["right", "down_right", "down", "down_left", "left", "up_left", "up", "up_right"] func direction_to_name(dir: Vector2) -> String: return DIR_NAMES[wrapi(roundi(dir.angle() / (PI / 4)), 0, 8)]效果与八分支版完全相同(键盘八方向逐个验证过),还天然适配摇杆的连续角度。
wrapi负责把 -43 的编号绕回 07(比如 -π 对应 -4,绕回 4 = "left")。现在看不懂没关系,它会在附录 F 的 API 速查表里等你。
5.3.4 实体化:碰撞体只包「脚」,原点就是「脚」
她现在满屏跑了——但她是「幽灵」,因为还没有碰撞体。这一小节把她变成「实体」,这也是本章开头预告的「最后一步」。先想清楚一个问题:俯视角游戏里,碰撞体该包住角色的多大范围?
直觉答案是「整个人」。错了。 主角单帧 128×128,在 640×480 的画面里占了约四分之一屏高——那是她的「画像高度」。但《酒魂》是俯视角,摄像机从斜上方看下去,真正「踩在地上、会被墙挡住」的只有她的脚那一小条。如果把碰撞体做成 128 高的大胶囊:
- 走到桌子旁边,头还没到就被弹开,视觉上离桌子一大截;
- 贴墙站立时,角色大半个身子悬在墙「里面」,画面穿帮。
正确做法:碰撞体只包脚底一小块,节点的原点(坐标 (0,0))就定在脚底。 按 128 的身高,碰撞体用一个小胶囊(约 44 宽 × 28 高),放在脚的位置。操作步骤:
- 给
Player添加子节点 CollisionShape2D,在检查器的 Shape 属性新建 CapsuleShape2D; - 把它的尺寸改为约 44 × 28(半径 22、高度 28);
- 把
CollisionShape2D的 Position 设为(0, -14)——让胶囊刚好贴在原点上方那一小格(原点是脚底,胶囊以原点为底边); - 选中
AnimatedSprite2D,把它的 Offset 设为(0, -64)——128 高的图,向上抬半个身位,让图的底边正好落在节点原点上(实测素材脚线在帧内最底 119128 之间,所以脚底距原点 09px,多数角度只有 3~5px,肉眼几乎无感;若你看着「悬浮」难受,把它微调成(0, -61))。
别把两个
-64搞混。 5.1.1 给相机的Position也是(0, -64),数值一样,管的事情完全不同:
- 相机 Position 管「眼睛看哪里」——镜头中心对准主角腰身,所以主角落在画面正中;
- 图的 Offset 管「图贴在节点的什么位置」——让 128 高的图底边贴住原点(也就是脚底)。
两者互不干涉。将来要调构图(比如让主角偏下一点、多露出前方视野),改相机;要修「人和碰撞体对不上位」,改 Offset。
⚠ 素材注记(如实相告): 实测这张表的各方向脚线没有逐帧对齐——「下」的脚线在帧内 y≈123,「右上」却到 128,相差最多 9px。表现为切换方向时角色有轻微的上下浮动。这是原始 AI 生成表的基线问题,附录 B 的清洗管线会统一基线;在那之前不影响本章任何功能,也不建议你手动逐帧去抠。
为什么要这么较真「原点在脚底」? 第 6 章做地图遮挡(Y-Sort:站在树后被树挡住、站在树前把树挡住)时,引擎是按节点原点的 y 值排序的。原点在脚底,「谁在前谁在后」的判断才符合视觉直觉。现在多花两分钟,第 6 章少调两小时——这种「为下一章埋伏笔」的细节,就是 4.2 节说的「场景树是动态骨架」的具体落地。
最后一步,给主角上「身份证」——碰撞层。Godot 用「物理层」决定谁和谁发生碰撞。全书约定(详见第 8 章 8.3):第 1 层是世界、第 2 层是玩家、第 3 层起是战斗判定:
- 打开「项目 → 项目设置 → 层名称 → 层名称 → 2D 物理」,把 Layer 1 命名为
world,Layer 2 命名为player(其余层以后用到再命名);
- 选中
Player节点,在检查器的 Collision 区域:- Layer 只勾 2 (player)——「我是玩家」;
- Mask 只勾 1 (world)——「我会被墙挡住」。

Layer 和 Mask 的区别,一句话记住: Layer 是「我是谁」,Mask 是「我在乎谁」。主角在乎世界(要被墙挡),不在乎自己(自己不会挡自己);敌人、UI 以后各上各的层,互不干扰——这就是用层管理碰撞的好处:不用写一行代码,用勾选就能划分阵营。
5.3.5 接入主场景测试
主角是独立场景,要「请」进主场景才能运行:
- 双击打开
scenes/main.tscn; - 从文件系统面板把
scenes/characters/player.tscn拖拽到CurrentLevel节点上松手(实例化,4.3.2 讲过的复用机制); - F5 运行——验收效果:
- 按 WASD / 方向键,角色朝八个方向行走,播放对应的行走动画;
- 镜头始终跟着她——相机就挂在
Player下面,主角走到哪,画面跟到哪; - 斜向走不比直走快(归一化生效);
- 松手后角色停下,面向最后移动的方向播待机动画;
- 此刻还「无墙可撞」,正常——第 6 章地图一来,5.3.4 的碰撞体立刻上岗。

为什么拖进
CurrentLevel而不是Main? 回看 4.2.2 的蓝图:CurrentLevel是「当前关卡容器」,主角属于关卡的一部分——将来切换地图时,整个关卡(包括主角位置的重置)一起更换。把东西放对层级,第 6 章的关卡切换才不用搬代码。
5.4 主角的序列化接口:to_dict() / from_dict()
这一节很短,但它是全书架构约定的第一块正式拼图,请别跳过。
5.4.1 为什么现在就写「存档接口」
第 21 章会做完整的存档系统,但它有一条铁律(见 21.1):每个系统自己提供 to_dict()(打包)和 from_dict()(还原),存档系统只负责把这些字典写进文件、读出来分发。 第 5、10、18、20 章都按这个约定给各自的系统配接口,第 21 章才能「只写调度、不写细节」。
为什么本章就要写?因为主角是第一个「拥有会变化的状态」的系统——位置和朝向每帧都在变。约定趁早建立,后面每章照方抓药;等攒了十个系统再回头补,就是一场大重构(第 2 章「小步快跑」方法的反面教材)。
还有一个设计决策要交代:本章只存两个字段——位置和朝向。 生命值(第 8 章)、醉意值(第 12 章)、背包(第 10 章)以后会陆续加进各自的 to_dict(),字段是随章节生长的;而 move_speed 这种「设计参数」不存——它属于配置(附录 E.1.2),不是玩家的游戏进度。分清「进度」和「配置」,存档才不会越做越乱。
5.4.2 实现接口
在 player.gd 末尾追加:
# ---------- 序列化接口(全书约定,第 21 章存档系统调用,参见 5.4 节) ----------
# 打包:把主角的「进度」装进一个字典,供存档系统写文件
func to_dict() -> Dictionary:
return {
"pos_x": position.x,
"pos_y": position.y,
"last_dir_x": last_direction.x,
"last_dir_y": last_direction.y,
}
# 还原:从字典恢复主角状态(第 21 章读档时调用)
# 用 data.get(键, 当前值) 兜底:旧存档缺字段时退回当前值,游戏不崩
func from_dict(data: Dictionary) -> void:
position = Vector2(data.get("pos_x", position.x), data.get("pos_y", position.y))
last_direction = Vector2(
data.get("last_dir_x", last_direction.x),
data.get("last_dir_y", last_direction.y)
)
update_animation("idle") # 面向恢复后的朝向
临时验证:在 _ready() 的末尾加一行,然后 F6 运行:
print("[验证] 主角状态打包: ", to_dict())
输出面板应打印类似 [验证] 主角状态打包: { "pos_x": 320, "pos_y": 240, "last_dir_x": 0, "last_dir_y": 1 }。看到它,说明打包通路已通(还原逻辑第 21 章读档时实测)。验证完这一行删不删都行——第 4 章 4.7 节说过,养成「临时代码用完处理」的习惯比这行代码本身重要。
为什么字典里存
pos_x/pos_y而不是直接塞一个 Vector2? JSON 文件里没有「Vector2」这种引擎类型,只有数字和字符串。存档的最终形态是 JSON(第 21 章),所以接口层就按「引擎类型 → 基础类型」打包,后面零转换。在数据的源头就按它的终点格式来,是数据驱动思路(4.6 节)的又一次落地。
5.5 动手:让主角在场景中能走
本章的动手环节贯穿 5.1~5.4,这里给你一张总验收清单——全部打勾,本章才算通关:
-
player.tscn保存在scenes/characters/,节点树齐全(Player + AnimatedSprite2D + Camera2D + CollisionShape2D); -
Camera2D挂在 Player 下、Position 为(0, -64),F6 和 F5 都能在画面里看到主角; -
idle.png/walk.png已拷入assets/sprites/player/,F6 运行无 preload 报错; - 16 个动画由
_build_animations()自动生成:8 个 idle @5FPS + 8 个 walk @8FPS,全部循环; - 碰撞体只包脚底(约 44×28,位置
(0, -14)),AnimatedSprite2D的 Offset 为(0, -64); - Player 的 Layer 勾 2 (player),Mask 勾 1 (world);
- 输入映射配好
left/right/up/down四个动作,各绑两套按键; - F5 运行:八方向行走、斜走不加速、停下面向来路、输出面板打印过状态字典。
常见问题排查(卡住时按现象对号入座):
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 运行后画面一片空白,看不见角色 | ① 没装相机——世界原点在画面左上角,主角站在 (0,0),整个人落在画面外(第一步就撞上这个坑的读者不少);② _build_animations 没被调用,贴图是空的;③ 主角位置被误改成了很大的坐标 |
按顺序查:① Player 下有没有 Camera2D、Position 是不是 (0, -64)(最常见);② _ready() 里有没有 _build_animations();③ 选中 Player 看 Position,应该是 (0, 0) |
| 一运行就报 preload 找不到资源 | 素材没拷进项目,或路径不符 | 核对 5.2.1:必须是 res://assets/sprites/player/idle.png(全小写) |
| 角色不动 | 输入映射没配,或动作名拼错 | 核对 5.3.2:四个动作名全小写,与代码里的字符串逐字一致 |
| 角色不动(有输入) | 脚本没挂到 Player 根节点 | 场景树里选中 Player,检查右上角是否挂着 player.gd |
| 报错 Invalid animation | 动画名拼接结果与生成的不一致 | 打印 anim_name 检查:idle_down 而不是 IdleDown 或 idle-down |
| 行走时角色原地滑步不迈腿 | walk 表行序与 DIR_ROWS 对不上 | 对照 5.2.1 行序表逐行核对(实测行序:0右 1左 2下 3上 4右下 5左下 6右上 7左上) |
| 切换方向时角色上下轻跳 | 原始表各方向脚线未对齐(差 0~9px) | 已知问题,附录 B 清洗管线统一基线;本章可忽略 |
| 斜向动画不对 | 分段边界写错 | 打印 dir.angle() 的值,对照 5.2.2 的角度表核对;或直接改用进阶查表版 |
| 停止后面向错误 | last_direction 没在移动时更新 |
确认 5.3.3 的 if direction != Vector2.ZERO 分支里有赋值那行 |
| 移动时画面发虚 | 纹理过滤不是最近邻 | 回到 4.4.1 第 ② 步,把默认纹理过滤改为 Nearest |
排查的思想武器是第 2 章的「二分定位」:先分清输入 → 逻辑 → 显示三段中哪一段断了(按 A 键时给
direction打个 print,一看便知),再在断的那段里找。
5.6 本章产出:《酒魂》的可控主角(完成度:10%)
本章你得到了什么
player.tscn+player.gd:八方向移动、八方向待机/行走动画、脚下碰撞、正确的物理层身份;- 一台挂在主角身上的跟随相机:画面永远跟着她走——第 6 章 6.6 节的「平滑跟随 + 边界锁定」就在这台上调优,不用重建;
- 一套「精灵表切帧」套路:
DIR_ROWS行序表 +_build_animations()+AtlasTexture——十几行代码吃下任何规格工整的精灵表,第 11 章的敌人、第 18 章的 NPC 全靠它; - 「先动起来,再实体化」的场景搭建顺序:身体 → 动画 → 移动 → 碰撞,每一步都有可见的验收效果;
- 弧度分段的通用方法:任何「按方向选行为」的需求(瞄准、抛物、受击方向)都从 5.2.2 这张表出发;
- 全书第一个序列化接口:
to_dict()/from_dict(),第 21 章存档系统的地基。
这些设施会被谁复用?
| 本章产出 | 建立位置 | 被后续哪些章节复用 |
|---|---|---|
| 身体 + 移动控制 | 5.1 / 5.3 | 第 11 章(敌人)、第 18 章(NPC 行走) |
| 主角跟随相机(Camera2D) | 5.1.1 | 第 6 章 6.6(平滑跟随与边界锁定)——在这台上调参,不要新建 |
| 精灵表切帧套路 | 5.2.3 | 第 11 章(敌人表)、第 18 章(NPC 表——行序与主角不同,届时以附录 B 的素材清单为准,别照抄 DIR_ROWS) |
| 八方向动画与角度分段 | 5.2.2 | 第 7 章(投壶瞄准)、第 8 章(攻击方向) |
| 碰撞层约定(2=player) | 5.3.4 | 第 8 章(Hitbox/Hurtbox 阵营判定) |
to_dict() / from_dict() |
5.4 | 第 21 章(存档汇总)、第 23 章(读档恢复) |
| 原点在脚底 + sprite Offset | 5.3.4 | 第 6 章(Y-Sort 遮挡)——不埋这颗钉子,第 6 章必返工 |
收尾:打上本章的存档点
git add .
git commit -m "第5章完成:可控主角(八方向移动与动画)"
git push origin master
git tag ch05
git push origin ch05
下一步: 主角会走了,但她脚下还是一片虚空。第 6 章我们用 TileMapLayer 画出江南小镇的第一张地图,把 5.1.1 那台相机调得更讲究(平滑跟随 + 地图边界锁定),顺便解决「站在树后会被树挡住」的遮挡问题——你在 5.3.4 埋下的那颗「原点在脚底」的钉子,马上就要派上用场。
延伸阅读
以下资源不是必读,卡住或想深入时再看:
-
Godot 官方文档(中文):CharacterBody2D:https://docs.godotengine.org/zh_CN/stable/classes/class_characterbody2d.html ——
move_and_slide与velocity的完整说明; -
Godot 官方文档(中文):输入示例:https://docs.godotengine.org/zh_CN/stable/tutorials/inputs/input_examples.html ——
Input.get_vector的官方用法与死区说明; -
Godot 官方文档(中文):AtlasTexture:https://docs.godotengine.org/zh_CN/stable/classes/class_atlastexture.html —— 图集纹理的更多玩法(本章「切精灵表」正是它最典型的用途)。
评论0
暂时没有评论