前面几十篇,我们让角色会走、会跳、会被 AI 追、会有光、会有粒子。但有一个「看不见的骨架」一直没单独讲透:射线(Ray)。你的敌人凭什么「看见」玩家?子弹凭什么「知道」自己打中了谁?角色凭什么在悬崖边停住脚步?——全靠一条从 A 点射到 B 点、返回「第一个撞到什么」的射线。今天把这套 2D 命中判定的基本功补齐,它是射击、潜行、AI 感知、平台跳跃的共同底座。

今日速览

  • Godot 有两套射线:拖进场景的 RayCast2D 节点(每物理帧自动更新、结果缓存),和代码式的 intersect_ray()(临时查询、按需触发)。
  • RayCast2D 的 target_position 是相对本节点的位移,不是世界坐标;exclude_parent 默认 true,会自动忽略自己的父节点碰撞体。
  • 代码式查询只能在 _physics_process() 里安全执行:物理空间在别的时机可能被锁住,在 _input() / _process() 里查会报错。
  • intersect_ray() 返回一个字典:没打中就是空字典 {};打中了包含 position / normal / collider / collider_id / rid / shape / metadata。
  • 想一次「扫过一个区域」而不是一条线,别堆十条射线——直接用 ShapeCast2D(扫形状),又快又准。

核心学习内容

1. 两条路:给节点,还是给代码?

RayCast2D 节点适合「每帧都要知道答案」的常驻检测:脚下有没有地面、前方有没有墙、面前是不是悬崖。它由引擎自动每物理帧算一次,你只要读:

@onready var ground_ray: RayCast2D = $GroundRay
@onready var ledge_ray: RayCast2D = $LedgeRay

func _physics_process(delta):
    var on_ground := ground_ray.is_colliding()          # 脚下有地
    var near_ledge := on_ground and not ledge_ray.is_colliding()  # 前方踩空

注意 is_colliding() 读的是上一个物理帧缓存。如果你在同一帧刚改过射线方向就要答案,得手动 force_raycast_update()。

代码式 intersect_ray() 适合「事件触发的临时检测」:开枪的瞬间、按下技能的那一刻。它不占节点,但要自己拿空间状态:

func _physics_process(delta):
    var space := get_world_2d().direct_space_state
    var query := PhysicsRayQueryParameters2D.create(global_position, target_position)
    var hit := space.intersect_ray(query)
    if hit:
        print("打中:", hit.collider, " 于 ", hit.position)

create(from, to, mask, exclude) 的第 3、4 个参数(碰撞遮罩、排除列表)是可选的,但实战里几乎必填——下面两条就是原因。

2. 视线检测:敌人为什么「看得见」你

最常见的第一个射线需求是视线(Line of Sight):敌人在距离内、在视锥角度内,而且中间没有墙挡住。前两个条件用数学算,第三个条件交给射线:

func can_see(target: Node2D) -> bool:
    var to_target := target.global_position - global_position
    if to_target.length() > vision_range:
        return false
    var diff := absf(wrapf(global_position.angle_to_point(target.global_position) - rotation, -PI, PI))
    if diff > deg_to_rad(fov_degrees * 0.5):
        return false
    var space := get_world_2d().direct_space_state
    var query := PhysicsRayQueryParameters2D.create(global_position, target.global_position, obstacle_mask)
    return space.intersect_ray(query).is_empty()  # 没被墙挡住 = 看得见

关键点:射线只查 obstacle_mask(墙壁那层),不去查玩家——否则「第一个撞到的就是玩家自己」,永远判定为被挡。

3. 命中扫描:子弹与鼠标拾取

**射击(hitscan)**不需要真的生成一颗飞行的子弹,只要「开枪这帧,从枪口沿朝向射一条最远 range 的线」,撞到谁就算打中谁。省性能、绝不打偏:

func fire() -> void:
    var space := get_world_2d().direct_space_state
    var dir := Vector2.RIGHT.rotated(rotation)
    var muzzle := global_position + dir * muzzle_offset
    var query := PhysicsRayQueryParameters2D.create(muzzle, muzzle + dir * max_range, hit_mask)
    query.exclude = [self, get_parent()]   # 别打中自己和枪的持有者
    var hit := space.intersect_ray(query)
    if hit:
        spawn_impact(hit.position, hit.normal)
        if hit.collider.has_method("take_damage"):
            hit.collider.take_damage(damage)

hit.normal(碰撞点法线)是免费的弹药:用它来决定火花朝哪边喷、弹孔怎么贴、击退往哪个方向推。exclude = [self, get_parent()] 是防自伤的固定动作。

在 3D / 鼠标拾取里,思路一致,只是起点方向要用相机换算:camera.project_ray_origin(mouse) 与 camera.project_ray_normal(mouse)。

4. 三个必踩的坑

  • 自交(self-hit):查询时没排除自己 → 第一个撞到的永远是自己。用 exclude(少量、动态)或碰撞层/遮罩(大量、固定)解决,后者性能更好。
  • 时机错误:在 _input() 或 _process() 里调 intersect_ray() → 空间被锁,报错。检测逻辑统一放进 _physics_process()。
  • 遮罩没配:射线 .collision_mask 和目标的 .collision_layer 必须有交集才检测得到。打不中先查这一条,比查代码快十倍。

🎯 DeepSeek 提示词

提示词 1:让 DeepSeek 写「敌人视线/感知组件」

适用场景:潜行、塔防、俯视射击里,给敌人加「看得见玩家才会追」的感知。

你是一名 Godot 4 (GDScript) 游戏程序员。请写一个可复用的「视线/感知」组件。

【角色】Godot 4.x,2D 俯视角游戏。
【任务】实现 VisionComponent(extends Node2D),提供 can_see(target: Node2D) -> bool。
【判定规则(全部满足才算看见)】
1. 目标到自身的距离 <= vision_range(@export,默认 300)。
2. 目标与自身朝向 rotation 的夹角 <= fov_degrees/2(@export,默认 90 度)。
3. 从自身到目标没有墙遮挡:用 get_world_2d().direct_space_state 的
   intersect_ray(),只查 obstacle_mask(@export,默认墙壁层),
   无命中即为「没被挡」。
【输出格式】只给一份完整 .gd 文件,含 @export 参数、中文注释、一个 _physics_process 里调用的示例。
【约束】
- 空间查询必须在 _physics_process 上下文安全执行,请注明调用时机。
- 禁止自己写物理数学导致视锥判断错误;角度用 wrapf/absf 处理 -PI~PI 环绕。
- 不要用 RayCast2D 节点,用代码式 intersect_ray。

提示词 2:让 DeepSeek 写「命中扫描(hitscan)武器」

适用场景:射击、激光、戳刺攻击——这一帧开火就要出结果。

你是一名 Godot 4 (GDScript) 游戏程序员。请实现一个 hitscan(命中扫描)开火逻辑。

【角色】Godot 4.x 2D 游戏,武器挂在角色下。
【任务】写 fire() 函数:从枪口沿当前 rotation 方向射一条最远 max_range 的射线。
【要求】
- 用 PhysicsRayQueryParameters2D.create(),碰撞遮罩用 hit_mask(@export)。
- 用 query.exclude 排除 self 与 get_parent(),防止自伤。
- 命中时:调用 spawn_impact(hit.position, hit.normal);若 hit.collider 有 take_damage 方法则造成 damage。
- 未命中时:调用 spawn_tracer(end_point) 画一条枪线。
【输出格式】完整 .gd 片段 + 10 行使用说明。
【约束】全部放在 _physics_process 触发的帧内执行;不得依赖飞行中的子弹节点;注释用中文。

提示词 3:让 DeepSeek 当「射线代码审查员」

适用场景:明明写了一堆射线,却「打不中 / 常撞到自己 / 有时报错」——拿它逐条排查。

你是 Godot 4 物理系统专家。下面是我的射线检测代码,请做代码审查。
【检查清单,逐项给出:问题 → 原因 → 修改后的代码】
1. 是否在 _physics_process 之外的时机调用 intersect_ray(空间锁定会报错)?
2. 是否漏了 exclude [self, get_parent()],导致自交?
3. 射线的 collision_mask 与目标 collision_layer 是否有交集?没有交集会静默失效。
4. 常驻检测是否该用 RayCast2D 节点(缓存结果)而非每帧新建查询?
5. 需要「扫区域」时是否错误地堆了多条射线,应该改用 ShapeCast2D?
【输出格式】先给一份问题列表(按严重程度排序),再给改正后的完整代码。
【约束】只针对我贴出的代码,不要泛泛而谈;指出具体行;保留我的命名风格。

我的代码:
(在此粘贴你的 .gd 代码)

学习心得

射线是那种「学会了就到处在用」的东西:它把「画面上的两个像素」翻译成「物理世界里的一条线」,让游戏第一次拥有了「感知」和「精确命中」。对「用 DeepSeek 做一款 Steam 游戏」这个目标来说,它尤其值——它是 AI 最容易一次写对、也最容易悄悄写错的系统:写对了,敌人机灵、子弹跟手;写错了(自交、遮罩没配),表现是「怪怪的但能跑」,最耗调试时间。今天这套「提示词要显式写清 exclude、碰撞遮罩、执行时机」的写法,正是为了让 DeepSeek 一次就把这三个坑堵上。

明日关注(checklist)

  • 把 can_see() 接进敌人 FSM:看不见 → 巡逻,看见 0.5 秒 → 追击,丢失 3 秒 → 返回。
  • 用 hit.normal 做一个「命中方向火花」,配合屏幕震动,验证打击感是否明显提升。
  • 搭一张「射线调试视图」:把命中点 hit.position 用 draw_line / draw_circle 画出来,肉眼确认每条射线到底打在哪。

本文由自动化学习流水线每日生成,坚持即学即记。