2D Godot 2D 게임 기초
P3 충돌과 상호작용

13강레이캐스트와 판정

시야, 발밑 감지, 히트스캔.

이 강의 목차 (7)
  1. "닿기 전에 알고 싶다"
  2. 방법 1 — RayCast2D 노드
  3. 방법 2 — 코드로 직접 쏘기
  4. 관련 도구들
  5. 실습 1 — 적의 시야 판정
  6. 실습 2 — 히트스캔 사격
  7. 디버그로 보이게 만들기
이번 강의의 목표
  • 레이캐스트가 무엇이고 어디에 쓰이는지 안다.
  • 노드 방식과 코드 방식(공간 질의) 둘 다 쓸 수 있다.
  • 적의 시야 판정히트스캔 사격을 만든다.

"닿기 전에 알고 싶다"#

12강의 Area2D이미 닿은 뒤에 알려줍니다. 그런데 게임에는 이런 질문이 널려 있습니다.

  • 이 방향에 벽이 있나? (닿기 전에)
  • 발밑에 땅이 있나? (한 발 내딛기 전에)
  • 적이 플레이어를 볼 수 있나? (사이에 벽이 있나?)
  • 총을 쏘면 뭐가 맞나? (총알을 실제로 날리지 않고)
  • 마우스로 클릭한 곳에 뭐가 있나?

이 전부를 하나의 도구로 해결합니다.

레이캐스트(Raycast)

"이 점에서 저 방향으로 선을 그었을 때, 처음 만나는 것은 무엇인가?" 를 물리 엔진에게 물어보는 것.

돌아오는 답은 네 가지입니다.

  • 뭔가에 맞았나 (is_colliding())
  • 맞은 물체는 무엇인가 (get_collider())
  • 어느 지점에서 맞았나 (get_collision_point())
  • 그 면은 어느 방향인가 (get_collision_normal())
레이저 포인터

어두운 방에서 레이저 포인터를 켜면 빛이 나가다가 처음 만난 물체에서 멈춥니다. 그 점이 collision_point, 그 물체가 collider, 그 벽면이 향한 방향이 normal입니다.

빛은 벽을 뚫지 않습니다. 뒤에 뭐가 더 있어도 처음 하나만 알려줍니다.

방법 1 — RayCast2D 노드#

가장 쉬운 방법입니다. 항상 같은 방향을 보는 감지기에 적합합니다.

text
Player          (CharacterBody2D)
├─ Sprite2D
├─ CollisionShape2D
└─ GroundCheck  (RayCast2D)   Target Position (0, 40)

주요 속성:

속성 의미
target_position 로컬 기준 끝점. (0, 40) = 아래로 40픽셀
enabled 켜짐/꺼짐
collision_mask 무엇을 감지할지 (11강)
exclude_parent 부모를 무시할까 (기본 켜짐)
hit_from_inside 도형 안에서 시작해도 감지할까
target_position은 "끝점"이지 "방향"이 아니다

(0, 1) 이라고 쓰면 아래로 1픽셀만 검사합니다. 방향 단위 벡터가 아닙니다. 길이가 곧 검사 거리입니다. (0, 40)이면 아래로 40픽셀까지 봅니다.

gdscript
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에서 읽으면 한 프레임 늦은 값일 수 있습니다.

같은 프레임 안에서 노드를 옮기고 즉시 결과가 필요하면 강제 갱신합니다.

gdscript
ray.target_position = Vector2(0, 60)
ray.force_raycast_update()  # 지금 당장 다시 쏜다
if ray.is_colliding():
	...

방법 2 — 코드로 직접 쏘기#

방향과 거리가 매번 달라지는 경우(총 쏘기, 마우스 클릭)에는 노드보다 이쪽이 편합니다.

gdscript
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의 거듭제곱입니다.

gdscript
# 1번(world)과 3번(enemy)을 보고 싶다면
q.collision_mask = (1 << 0) | (1 << 2)  # = 1 | 4 = 5

읽기 어려우므로 상수로 빼는 걸 권합니다.

gdscript
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 이동 가능 거리 벽까지 얼마나 갈 수 있나
ShapeCast2D — 두께가 있는 레이캐스트

레이는 굵기가 0인 선입니다. 그래서 아주 얇은 틈을 지나가 버립니다.

ShapeCast2D는 원이나 사각형을 그 방향으로 밀어보며 검사합니다.

  • 굵은 총알
  • 캐릭터가 지나갈 수 있는 통로인지 확인
  • 여러 대상을 한 번에 감지 (레이는 첫 번째만)

실습 1 — 적의 시야 판정#

목표: 적이 플레이어를 "볼 수 있을 때만" 추격한다. 사이에 벽이 있으면 못 본 척해야 합니다.

gdscript
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 — 히트스캔 사격#

총알을 실제로 날리지 않고 즉시 판정하는 방식입니다. 저격총, 레이저, 빠른 총기에 씁니다.

gdscript
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)
히트스캔 vs 발사체
히트스캔 발사체(Projectile)
판정 쏘는 즉시 날아가서 닿을 때
회피 불가능 피할 수 있음
네트워크 동기화 쉬움 동기화 어려움 (23강)
느낌 즉각적, 딱딱함 예측 가능, 전략적
저격총, 레이저 로켓, 화살, 탄막

둘 중 무엇을 쓸지는 기획 결정입니다. 성능 문제가 아니라 게임성 문제입니다.

디버그로 보이게 만들기#

레이캐스트는 눈에 안 보여서 디버깅이 어렵습니다. 직접 그립니다.

gdscript
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()

_draw()노드의 로컬 좌표 기준으로 직접 그립니다. 자동으로 매 프레임 불리지 않고, queue_redraw() 를 불러야 다음 프레임에 다시 그립니다.

디버그 시각화, 간단한 도형, 커스텀 그래프에 유용합니다.

직접 바꿔보기
  1. sight_angle을 360으로 만들어보세요. 적이 뒤통수도 봅니까?
  2. 시선 차단 검사(3번)를 지워보세요. 적이 벽 너머로 쫓아옵니까?
  3. sight_range를 2000으로 올리고 적을 20마리 놓아보세요. FPS가 떨어집니까? 그다음 거리 검사를 맨 마지막으로 옮겨보세요. 더 떨어집니까?
  4. 히트스캔을 발사체(11강의 총알)로 바꿔보세요. 느낌이 어떻게 다릅니까?
  • 개발자direct_space_state 질의는 _physics_process 안에서만 안전합니다. _process에서 부르면 물리 서버가 잠겨 있어 오류가 나거나 낡은 결과를 받습니다.
  • 기획자 — "적이 벽 너머로 쫓아와요"는 시선 차단 검사가 없는 것입니다. 반대로 "적이 너무 눈치가 없어요"는 시야각이 좁은 것입니다. 적 AI 스펙에 시야 거리 / 시야각 / 시야 상실 유예시간 세 항목을 넣어주세요.
  • 디자이너 — 시야를 플레이어에게 보여주는 게임(스텔스)이라면 시야 콘을 그려야 합니다. Polygon2DLight2D로 표현하며, 실제 판정과 그림이 어긋나면 플레이어가 즉시 알아챕니다.
자주 나는 오류

"레이가 아무것도 못 맞힌다" → ① collision_mask 확인, ② target_position이 너무 짧음, ③ 상대가 Area2D인데 collide_with_areas가 꺼져 있음(기본 꺼짐).

"레이가 자기 자신을 맞힌다"exclude = [self] 를 넣거나 exclude_parent를 켜세요.

"움직인 직후 결과가 이상하다"force_raycast_update() 를 부르세요.

"_process에서 intersect_ray를 부르니 오류" → 물리 프레임에서 부르세요.

여기까지 확인
  • 레이캐스트가 무엇을 반환하는지 안다
  • 노드 방식과 코드 방식을 둘 다 써봤다
  • 시야 판정을 거리 → 각도 → 차단 순서로 만들었다
  • 히트스캔과 발사체의 차이를 안다

P3 정리 — 이제 게임의 규칙이 돕니다. 때리고, 맞고, 줍고, 감지합니다.

다음 강의 — P4로 갑니다. 같은 게임을 완성돼 보이게 만드는 기술. 카메라부터 시작합니다.