Godot 3.0: Visibility with Ray-casting
Sat, Mar 10, 2018This tutorial shows how to use the ray-casting feature of Godot’s physics engine (not the RayCast2D node) to make entities that can’t see through walls.
We’ll use a TileMap for walls and create some rotating turrets that look for the player and shoot when they can see it.
Note: This is not a beginner-level tutorial. It assumes you have a general knowledge of Godot. If you’re not there yet, start here.
You can watch a video version of this lesson here:
Introduction
We’re going to skip over the basic setup of the project and just summarize the setup here.
You can download the starting project here: raycasting_demo_start.zip
At the start, we have a Main
scene that contains a TileMap and instances of the Player
and a Turret
. The Player
is a KinematicBody2D
with basic 8-way movement using move_and_slide()
. See the project for the code, and see Using KinematicBody2D for more details on how this type of node works.
We have the Turret
node setup to detect the player and fire.
We use the turret’s Area2D to detect the player entering and exiting. The code for the Turret
looks like this:
extends KinematicBody2D
export (int) var detect_radius # size of the visibility circle
export (float) var fire_rate # delay time (s) between bullets
export (PackedScene) var Bullet
var vis_color = Color(.867, .91, .247, 0.1)
var target # who are we shooting at?
var can_shoot = true
func _ready():
# dim the sprite when not active
$Sprite.self_modulate = Color(0.2, 0, 0)
# set the collision area's radius
var shape = CircleShape2D.new()
shape.radius = detect_radius
$Visibility/CollisionShape2D.shape = shape
$ShootTimer.wait_time = fire_rate
func _physics_process(delta):
update()
# if there's a target, rotate towards it and fire
if target:
rotation = (target.position - position).angle()
if can_shoot:
shoot(target.position)
func shoot(pos):
var b = Bullet.instance()
var a = (pos - global_position).angle()
b.start(global_position, a + rand_range(-0.05, 0.05))
get_parent().add_child(b)
can_shoot = false
$ShootTimer.start()
func _draw():
# display the visibility area
draw_circle(Vector2(), detect_radius, vis_color)
func _on_Visibility_body_entered(body):
# connect this to the "body_entered" signal
if target:
return
target = body
$Sprite.self_modulate.r = 1.0
func _on_Visibility_body_exited(body):
# connect this to the "body_exited" signal
if body == target:
target = null
$Sprite.self_modulate.r = 0.2
func _on_ShootTimer_timeout():
can_shoot = true
This is fine in an open area, but if the player is behind a wall, the turret can still “see” them. This is what we are going to fix.
Ray-casting
Godot has a RayCast2D node that can report collisions. However, there are situations where using the node will not be practical. For example, if you need to cast a large number of rays dynamically, it is very inefficient to instance nodes for each one, especially since the physics engine is going to do the actual collision detection anyway.
Fortunately, we can access the physics server’s state directly and use it for collision checks. We can do this using
get_world_2d().direct_space_state
Once we have access to the space, we can use the intersect_ray()
function to cast a ray. If a collision occurs, the function will return useful information about the collision.
Change the code in _physics_process()
to the following:
func _physics_process(delta):
update()
if target:
aim()
Instead of just shooting the target, we’re going to try and aim at them first. We also want to be able to visualize what’s going on, so add a var hit_pos
at the top - this variable will store the position where the raycast hit so that we can draw it.
In the aim()
function we’re going to cast the ray. The function works like this:
intersect_ray(start, end, exceptions, mask)
Note that the start
and end
coordinates need to be in global coordinates. exceptions
lets us list any objects we want the ray to ignore and mask
defines the collision layers they ray should scan.
In the turret’s case the ray will be cast from the turret to the player’s position. We need to list the turret itself as an exception, or else the ray won’t travel at all!. Finally, since the Turret
node’s collision mask is set to only detect walls (environment) and the player, we can use that same mask for the ray.
Here is what the aim()
function looks like:
func aim():
var space_state = get_world_2d().direct_space_state
var result = space_state.intersect_ray(position, target.position,
[self], collision_mask)
if result:
hit_pos = result.position
if result.collider.name == 'Player':
$Sprite.self_modulate.r = 1.0
rotation = (target.position - position).angle()
if can_shoot:
shoot(target.position)
If the ray intersects something, it could either be a wall or the player. Only if it’s the player do we shoot as before. Also, move the self_modulate
from _on_Visibility_body_entered()
and put it here, so that the turret only lights up red when the player can be seen.
We also stored the coordinates of the collision in hit_pos
so that we can draw the ray. Add the following to _draw()
:
func _draw():
draw_circle(Vector2(), detect_radius, vis_color)
if target:
draw_line(Vector2(), (hit_pos - position).rotated(-rotation), laser_color)
draw_circle((hit_pos - position).rotated(-rotation), 5, laser_color)
Looking good! You can see the red line of the ray, and when it runs into a wall the turret remains inactive. However, we now have a different problem:
Casting Multiple Rays
Since the ray is being cast toward the player’s position
, which is the center of the player’s sprite, the turret can’t “see” the player even when it should. To solve this, we need to cast rays to the player’s corners, not its center. Now we will have a list of hits, so we must make hit_pos
an array.
To get the player’s four corner positions, we use the extents
of its collision shape. We’re also subtracting (5, 5)
to give a little leeway for the width of the bullets. The four corners are labeled nw
, ne
, se
, and sw
, and a ray is cast towards each:
func aim():
hit_pos = []
var space_state = get_world_2d().direct_space_state
var target_extents = target.get_node('CollisionShape2D').shape.extents - Vector2(5, 5)
var nw = target.position - target_extents
var se = target.position + target_extents
var ne = target.position + Vector2(target_extents.x, -target_extents.y)
var sw = target.position + Vector2(-target_extents.x, target_extents.y)
for pos in [nw, ne, se, sw]:
var result = space_state.intersect_ray(position,
pos, [self], collision_mask)
if result:
hit_pos.append(result.position)
if result.collider.name == "Player":
$Sprite.self_modulate.r = 1.0
rotation = (target.position - position).angle()
if can_shoot:
shoot(pos)
And we need to change the draw function to use the array of hits:
func _draw():
draw_circle(Vector2(), detect_radius, vis_color)
if target:
for hit in hit_pos:
draw_circle((hit - position).rotated(-rotation), 5, laser_color)
draw_line(Vector2(), (hit - position).rotated(-rotation), laser_color)
We’re almost there, but now you will notice that when the turret has a straight shot towards the player, it still shoots at the player’s corner! We should add the center position back in, and check it first. We also can add a break
statement to the loop - if one of the rays hits, we don’t need to continue checking the rest of them. The final aim()
function looks like this:
func aim():
hit_pos = []
var space_state = get_world_2d().direct_space_state
var target_extents = target.get_node('CollisionShape2D').shape.extents - Vector2(5, 5)
var nw = target.position - target_extents
var se = target.position + target_extents
var ne = target.position + Vector2(target_extents.x, -target_extents.y)
var sw = target.position + Vector2(-target_extents.x, target_extents.y)
for pos in [target.position, nw, ne, se, sw]:
var result = space_state.intersect_ray(position,
pos, [self], collision_mask)
if result:
hit_pos.append(result.position)
if result.collider.name == "Player":
$Sprite.self_modulate.r = 1.0
rotation = (target.position - position).angle()
if can_shoot:
shoot(pos)
break
Conclusion
Hopefully, you’ll find this technique useful for your own projects. A similar process can be used for laser shots instead of bullets (draw the line for a brief time, or try using Line2D instead of draw_line()
for a different effect). You can implement ricochets by taking the ray collision point and casting another ray from that point at the reflected angle.
If you enjoyed this and found it useful, please let me know in the comments below. I’m always happy to hear requests and suggestions for other gamedev-related material.