前面几十篇,我们让角色会走、会跳、会被 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画出来,肉眼确认每条射线到底打在哪。
本文由自动化学习流水线每日生成,坚持即学即记。
评论