13강레이캐스트와 판정
시야, 발밑 감지, 히트스캔.
이 강의 목차 (7)
- 레이캐스트가 무엇이고 어디에 쓰이는지 안다.
- 노드 방식과 코드 방식(공간 질의) 둘 다 쓸 수 있다.
- 적의 시야 판정과 히트스캔 사격을 만든다.
"닿기 전에 알고 싶다"#
12강의 Area2D는 이미 닿은 뒤에 알려줍니다. 그런데 게임에는 이런 질문이 널려 있습니다.
- 이 방향에 벽이 있나? (닿기 전에)
- 발밑에 땅이 있나? (한 발 내딛기 전에)
- 적이 플레이어를 볼 수 있나? (사이에 벽이 있나?)
- 총을 쏘면 뭐가 맞나? (총알을 실제로 날리지 않고)
- 마우스로 클릭한 곳에 뭐가 있나?
이 전부를 하나의 도구로 해결합니다.
"이 점에서 저 방향으로 선을 그었을 때, 처음 만나는 것은 무엇인가?" 를 물리 엔진에게 물어보는 것.
돌아오는 답은 네 가지입니다.
- 뭔가에 맞았나 (
is_colliding()) - 맞은 물체는 무엇인가 (
get_collider()) - 어느 지점에서 맞았나 (
get_collision_point()) - 그 면은 어느 방향인가 (
get_collision_normal())
어두운 방에서 레이저 포인터를 켜면 빛이 나가다가 처음 만난 물체에서 멈춥니다.
그 점이 collision_point, 그 물체가 collider, 그 벽면이 향한 방향이 normal입니다.
빛은 벽을 뚫지 않습니다. 뒤에 뭐가 더 있어도 처음 하나만 알려줍니다.
방법 1 — RayCast2D 노드#
가장 쉬운 방법입니다. 항상 같은 방향을 보는 감지기에 적합합니다.
Player (CharacterBody2D)
├─ Sprite2D
├─ CollisionShape2D
└─ GroundCheck (RayCast2D) Target Position (0, 40)주요 속성:
| 속성 | 의미 |
|---|---|
target_position |
로컬 기준 끝점. (0, 40) = 아래로 40픽셀 |
enabled |
켜짐/꺼짐 |
collision_mask |
무엇을 감지할지 (11강) |
exclude_parent |
부모를 무시할까 (기본 켜짐) |
hit_from_inside |
도형 안에서 시작해도 감지할까 |
(0, 1) 이라고 쓰면 아래로 1픽셀만 검사합니다. 방향 단위 벡터가 아닙니다.
길이가 곧 검사 거리입니다. (0, 40)이면 아래로 40픽셀까지 봅니다.
extends CharacterBody2D
@onready var ground: RayCast2D = $GroundCheck
func _physics_process(_delta: float) -> void:
if ground.is_colliding():
var hit := ground.get_collider()
var point := ground.get_collision_point()
print("발밑: ", hit.name, " @ ", point)RayCast2D는 물리 스텝마다 한 번 결과를 갱신합니다.
_process에서 읽으면 한 프레임 늦은 값일 수 있습니다.
같은 프레임 안에서 노드를 옮기고 즉시 결과가 필요하면 강제 갱신합니다.
ray.target_position = Vector2(0, 60)
ray.force_raycast_update() # 지금 당장 다시 쏜다
if ray.is_colliding():
...방법 2 — 코드로 직접 쏘기#
방향과 거리가 매번 달라지는 경우(총 쏘기, 마우스 클릭)에는 노드보다 이쪽이 편합니다.
func _shoot() -> void:
var space := get_world_2d().direct_space_state
var from := global_position
var to := from + Vector2.RIGHT.rotated(rotation) * 800.0
var q := PhysicsRayQueryParameters2D.create(from, to)
q.exclude = [self] # 나 자신은 무시
q.collision_mask = 1 | 4 # world(1) + enemy(3번=4)
q.collide_with_areas = false
var hit := space.intersect_ray(q)
if hit.is_empty():
return
print("맞음: ", hit.collider.name)
print("위치: ", hit.position)
print("법선: ", hit.normal)반환값은 사전(Dictionary)입니다. 아무것도 안 맞으면 빈 사전입니다.
| 키 | 내용 |
|---|---|
collider |
맞은 노드 |
collider_id |
그 노드의 ID |
position |
맞은 지점 (글로벌) |
normal |
맞은 면의 법선 |
rid |
물리 서버 내부 ID |
마스크 비트를 계산하는 법
collision_mask는 비트마스크입니다. 1번 레이어 = 1, 2번 = 2, 3번 = 4, 4번 = 8…
2의 거듭제곱입니다.
# 1번(world)과 3번(enemy)을 보고 싶다면
q.collision_mask = (1 << 0) | (1 << 2) # = 1 | 4 = 5읽기 어려우므로 상수로 빼는 걸 권합니다.
const L_WORLD := 1 << 0
const L_ENEMY := 1 << 2
q.collision_mask = L_WORLD | L_ENEMY관련 도구들#
레이(선) 말고도 몇 가지가 더 있습니다.
| 도구 | 무엇 | 용도 |
|---|---|---|
intersect_ray |
선 하나 | 사격, 시야, 발밑 |
intersect_point |
점 하나 | 마우스로 클릭한 대상 찾기 |
intersect_shape |
도형 하나 | 범위 안의 모든 대상 |
ShapeCast2D |
도형을 밀어보기 | 두꺼운 총알, 캐릭터 이동 예측 |
cast_motion |
이동 가능 거리 | 벽까지 얼마나 갈 수 있나 |
레이는 굵기가 0인 선입니다. 그래서 아주 얇은 틈을 지나가 버립니다.
ShapeCast2D는 원이나 사각형을 그 방향으로 밀어보며 검사합니다.
- 굵은 총알
- 캐릭터가 지나갈 수 있는 통로인지 확인
- 여러 대상을 한 번에 감지 (레이는 첫 번째만)
실습 1 — 적의 시야 판정#
목표: 적이 플레이어를 "볼 수 있을 때만" 추격한다. 사이에 벽이 있으면 못 본 척해야 합니다.
extends CharacterBody2D
@export var speed := 140.0
@export var sight_range := 380.0
@export var sight_angle := 70.0 # 시야각(도), 좌우 합계
var _target: Node2D
var _can_see := false
func _ready() -> void:
_target = get_tree().get_first_node_in_group("player")
func _physics_process(delta: float) -> void:
_can_see = _check_sight()
if _can_see:
var dir := global_position.direction_to(
_target.global_position)
velocity = velocity.move_toward(
dir * speed, 900.0 * delta)
else:
velocity = velocity.move_toward(
Vector2.ZERO, 700.0 * delta)
move_and_slide()
func _check_sight() -> bool:
if _target == null or not is_instance_valid(_target):
return false
var to_target := _target.global_position - global_position
# 1) 거리 검사 — 가장 싸므로 먼저
if to_target.length() > sight_range:
return false
# 2) 각도 검사 — 그다음으로 싸다
var facing := Vector2.RIGHT.rotated(rotation)
var half := deg_to_rad(sight_angle) * 0.5
if absf(facing.angle_to(to_target)) > half:
return false
# 3) 시선 차단 검사 — 가장 비싸므로 마지막
var space := get_world_2d().direct_space_state
var q := PhysicsRayQueryParameters2D.create(
global_position, _target.global_position)
q.exclude = [self]
q.collision_mask = 1 # 벽(world)만 본다
var hit := space.intersect_ray(q)
# 벽에 막히지 않았으면 보인다.
return hit.is_empty()거리 → 각도 → 레이캐스트 순서로 검사한 게 핵심입니다.
- 거리 검사: 뺄셈 몇 번. 매우 쌈
- 각도 검사: 삼각함수 한 번. 쌈
- 레이캐스트: 물리 엔진 질의. 비쌈
적이 100마리면 이 순서 하나로 성능이 몇 배 차이납니다. 싼 검사로 대부분을 걸러내고, 살아남은 것만 비싼 검사에 넘긴다 — 게임 최적화의 기본 패턴입니다.
실습 2 — 히트스캔 사격#
총알을 실제로 날리지 않고 즉시 판정하는 방식입니다. 저격총, 레이저, 빠른 총기에 씁니다.
extends Node2D
@export var range_px := 900.0
@export var damage := 25
func shoot(dir: Vector2) -> void:
var space := get_world_2d().direct_space_state
var from := global_position
var to := from + dir.normalized() * range_px
var q := PhysicsRayQueryParameters2D.create(from, to)
q.exclude = [get_parent()]
q.collision_mask = 1 | 4 # world + enemy
var hit := space.intersect_ray(q)
var end_point: Vector2 = to
if not hit.is_empty():
end_point = hit.position
var body = hit.collider
if body.has_method("take_damage"):
body.take_damage(damage)
_spawn_impact(hit.position, hit.normal)
_draw_tracer(from, end_point)
func _spawn_impact(pos: Vector2, normal: Vector2) -> void:
# 벽에 튀는 불꽃. 법선 방향으로 회전시켜 붙인다.
pass
func _draw_tracer(a: Vector2, b: Vector2) -> void:
var line := Line2D.new()
line.add_point(a)
line.add_point(b)
line.width = 2.0
line.default_color = Color(1, 0.9, 0.4, 0.9)
get_tree().current_scene.add_child(line)
var tw := line.create_tween()
tw.tween_property(line, "modulate:a", 0.0, 0.08)
tw.tween_callback(line.queue_free)| 히트스캔 | 발사체(Projectile) | |
|---|---|---|
| 판정 | 쏘는 즉시 | 날아가서 닿을 때 |
| 회피 | 불가능 | 피할 수 있음 |
| 네트워크 | 동기화 쉬움 | 동기화 어려움 (23강) |
| 느낌 | 즉각적, 딱딱함 | 예측 가능, 전략적 |
| 예 | 저격총, 레이저 | 로켓, 화살, 탄막 |
둘 중 무엇을 쓸지는 기획 결정입니다. 성능 문제가 아니라 게임성 문제입니다.
디버그로 보이게 만들기#
레이캐스트는 눈에 안 보여서 디버깅이 어렵습니다. 직접 그립니다.
func _draw() -> void:
if not Engine.is_editor_hint() and OS.is_debug_build():
var col := Color.RED if _can_see else Color.GRAY
draw_line(Vector2.ZERO,
to_local(_target.global_position), col, 1.0)
func _process(_delta: float) -> void:
queue_redraw() # _draw를 다시 부르게 한다_draw() 는 노드의 로컬 좌표 기준으로 직접 그립니다.
자동으로 매 프레임 불리지 않고, queue_redraw() 를 불러야 다음 프레임에 다시 그립니다.
디버그 시각화, 간단한 도형, 커스텀 그래프에 유용합니다.
sight_angle을 360으로 만들어보세요. 적이 뒤통수도 봅니까?- 시선 차단 검사(3번)를 지워보세요. 적이 벽 너머로 쫓아옵니까?
sight_range를 2000으로 올리고 적을 20마리 놓아보세요. FPS가 떨어집니까? 그다음 거리 검사를 맨 마지막으로 옮겨보세요. 더 떨어집니까?- 히트스캔을 발사체(11강의 총알)로 바꿔보세요. 느낌이 어떻게 다릅니까?
- 개발자 —
direct_space_state질의는_physics_process안에서만 안전합니다._process에서 부르면 물리 서버가 잠겨 있어 오류가 나거나 낡은 결과를 받습니다. - 기획자 — "적이 벽 너머로 쫓아와요"는 시선 차단 검사가 없는 것입니다. 반대로 "적이 너무 눈치가 없어요"는 시야각이 좁은 것입니다. 적 AI 스펙에 시야 거리 / 시야각 / 시야 상실 유예시간 세 항목을 넣어주세요.
- 디자이너 — 시야를 플레이어에게 보여주는 게임(스텔스)이라면
시야 콘을 그려야 합니다.
Polygon2D나Light2D로 표현하며, 실제 판정과 그림이 어긋나면 플레이어가 즉시 알아챕니다.
"레이가 아무것도 못 맞힌다"
→ ① collision_mask 확인, ② target_position이 너무 짧음,
③ 상대가 Area2D인데 collide_with_areas가 꺼져 있음(기본 꺼짐).
"레이가 자기 자신을 맞힌다"
→ exclude = [self] 를 넣거나 exclude_parent를 켜세요.
"움직인 직후 결과가 이상하다"
→ force_raycast_update() 를 부르세요.
"_process에서 intersect_ray를 부르니 오류" → 물리 프레임에서 부르세요.
- 레이캐스트가 무엇을 반환하는지 안다
- 노드 방식과 코드 방식을 둘 다 써봤다
- 시야 판정을 거리 → 각도 → 차단 순서로 만들었다
- 히트스캔과 발사체의 차이를 안다
P3 정리 — 이제 게임의 규칙이 돕니다. 때리고, 맞고, 줍고, 감지합니다.
다음 강의 — P4로 갑니다. 같은 게임을 완성돼 보이게 만드는 기술. 카메라부터 시작합니다.