更新记录

  • 2026-08-05:开始跟练 Metroid
  • 2026-08-06:实现如下功能
    1. 角色水平移动
    2. 加入跳跃和重力
    3. 添加可调节的跳跃力度和重力
    4. 添加射击以及冷却功能
    5. 实现固定方向射击
    6. 实现发射方向跟随鼠标移动变化,标准化向量
    7. 实现子弹生成
    8. 设置子弹初始位置
    9. 实现子弹移动

资源管理

  • 文件名和节点的命名

    通常建议:

    • 文件名:lower_snake_case
    • 节点名:PascalCase
    • 脚本变量:snake_case
    • 类名:PascalCase

.tscn和实例化

player.tscn 实例化到关卡后,关卡里保存的是对玩家场景的引用。把脚本挂在 player.tscn的根节点 Player 上,那么这个场景实例化出来的所有玩家,默认都会运行同一个脚本。

但是,每个实例的运行状态是独立的。虽然它们共用同一份脚本代码,但不是共用同一个对象。

一般会把“所有玩家都应该具备的功能”写进 player.tscn 的脚本,把“这个玩家单独的参数”在关卡实例上调整。

Vector2

二维数据内置类型。

表示速度时。

velocity = Vector2(100, 0)

表示每秒向右移动一定距离。

更常见的写法是:

velocity = direction * speed

例如:

var direction = Vector2.RIGHT
var speed = 200.0

velocity = direction * speed

结果就是:

velocity = Vector2(200, 0)

也就是向右移动,速度为 200。

为什么由 Level 创建子弹

玩家按下射击键后,子弹需要被添加到游戏场景中。

如果把子弹添加为 Player 的子节点:

Level
└── Player
    └── Bullet

子弹仍然属于 Player,可能受到玩家移动、旋转或删除的影响。

更合理的结构是:

Level
├── Player
└── Bullet

子弹和玩家都属于 Level。这样子弹射出后可以在关卡中独立移动,不会继续跟随玩家。

各节点的职责

  • Player:检测玩家是否按下射击键。
  • Level:接收射击请求,并把子弹添加到关卡中。
  • Bullet:负责自己的移动、碰撞和销毁。

射击流程

玩家按下射击键
Player 发出射击请求
Level 收到请求
Level 创建 Bullet
Bullet 在关卡中独立移动

为什么要在 Level 上挂脚本

因为 Level 是 Player 和 Bullet 的共同父节点,它适合负责创建和管理关卡中的物体。

可以简单理解为:

Player 负责扣动扳机,Level 负责容纳射出去的子弹。

这不是唯一的实现方法,但可以避免子弹成为 Player 的子节点,也方便以后统一管理玩家子弹、敌人子弹和其他关卡物体。

信号

Godot 的“信号”可以理解为一种节点之间发送通知的机制。

某个节点发生了一件事,它不直接命令其他节点做什么,而是发出一个信号。对这个信号感兴趣的节点,通过“连接”接收通知并执行对应函数。

一个完整的信号过程包括:

声明信号 → 连接信号 → 发出信号 → 接收并处理

signal = 定义一个通知 emit = 发出通知 connect = 指定谁接收通知 回调函数 = 收到通知后执行的代码

在射击功能中,只需要这样理解:

Player 按下射击键
→ 发出 shoot 信号
→ Level 收到信号
→ Level 创建子弹

信号可以连接多个接收者

一个信号不只能连接一个函数。

例如 Player 射击时:

Player.shoot
├── Level 创建子弹
├── AudioManager 播放声音
├── Camera 产生震动
└── UI 减少弹药数量

Player 只需要发出一次:

shoot.emit(pos, dir)

多个接收者都可以收到通知。

这也是信号的重要作用:发送者不需要知道有多少接收者。

子弹位置和鼠标方向的传递路径

1. Player 定义射击信号

signal shoot(pos: Vector2, dir: Vector2)

shoot 信号携带两个数据:

  • pos:子弹的生成位置
  • dir:子弹的飞行方向

2. Player 获取鼠标方向并发出信号

if Input.is_action_just_pressed("shoot") and $ReloadTimer.time_left == 0:
    # 获取并标准化鼠标方向,保证子弹速度固定
    shoot.emit(position, get_local_mouse_position().normalized())
    $ReloadTimer.start()

Player 发出的两个值是:

position                                → Player 当前的位置
get_local_mouse_position().normalized() → Player 指向鼠标的方向

get_local_mouse_position() 获取鼠标相对于 Player 的位置向量。

normalized() 将向量长度变成 1,只保留方向,避免鼠标距离影响子弹速度。

例如:

鼠标在右边 → 方向接近 (1, 0)
鼠标在左边 → 方向接近 (-1, 0)
鼠标在右上 → 方向接近 (0.7, -0.7)

ReloadTimer 用于控制射击间隔,冷却没有结束时不能再次射击。

3. Level 接收信号

func _on_player_shoot(pos: Vector2, dir: Vector2) -> void:
    var bullet = bullet_scene.instantiate() as Area2D
    $Bullets.add_child(bullet)
    bullet.setup(pos, dir)

数据按照参数顺序传递:

Player.position
    → Level.pos

Player 获取到的鼠标方向
    → Level.dir

Level 根据 bullet.tscn 创建一颗子弹,然后把位置和方向继续传给 Bullet。

4. Bullet 接收位置和方向

var direction: Vector2

func setup(pos: Vector2, dir: Vector2):
    position = pos + dir * 16
    direction = dir

这里:

  • position = pos + dir * 16:让子弹在玩家前方 16 像素处生成。
  • direction = dir:保存鼠标方向,供移动代码使用。

5. Bullet 按照方向移动

func _physics_process(delta: float) -> void:
    position += direction * 30 * delta

子弹每个物理帧都会沿着 direction 移动。

因为方向已经使用了 normalized(),所以鼠标离玩家的距离不会改变子弹速度。

完整传递路径

Player 获取自己的位置
Player 获取并标准化鼠标方向
shoot.emit(position, mouse_direction)
Level._on_player_shoot(pos, dir)
Level 创建 Bullet
bullet.setup(pos, dir)
Bullet 设置初始位置并保存方向
Bullet 每帧沿鼠标方向移动

虽然 Player、Level 和 Bullet 中都出现了 posdir,但它们是不同函数中的局部参数。

它们通过参数顺序传递同一组数据:

Player 的第一个参数 → Level 的第一个参数 → Bullet 的第一个参数
Player 的第二个参数 → Level 的第二个参数 → Bullet 的第二个参数