Aims of this Tutorial
- Learn the shortcomings of the current version and come up with a plan to improve it.
- Use a firing arc in front of the player to decide who to shoot.
- Make the laser visually adjust to shoot at the enemy instead of straight forward.
What is wrong with what we currently have?
Some test matches with friends revealed that, in practise, it is very hard to hit your opponent when you have a one dimensional line trying to intercept small objects in 3D space. This makes fight very boring as you are just trying to get some damage in. The solution to this is to use a firing arc that allows for a little bit of misalignment, thus making it easier to deal damage, thereby raising the stake.
Now that we have this system, we also need to consider what this is going to look like in game. We need the laser to actually fire at our target, so we need to rotate it. We can actually use the same code to figure out how long to make the laser as we had before, but we will no longer be using the raycast to determine if we are doing damage and the laser will be entirely cosmetic.
Deciding who to shoot
Creating the cone
Firstly we need to create a cone for collisions. We can make this easily in OpenSCAD in one line. However, we will want to use an extra line to increase the facet number. Since this is being used for collisions and is not visible, it is not imperative that the cone looks smooth, and a lower facet number will make the detection much less computationally expensive. We can use a cylinder with one radius as 0 to create the cone in OpenSCAD.
$fn = 25; cylinder(10, 1, 0);
Now we need to bring this into Godot, so like before we need to use a blender script to convert to an obj file. If you need a refresher, just run
blender --background --python convert-stl-obj.py -- Cone.stl Cone.obj with the
convert-stl-obj.py file being given below.
#!/bin/python import bpy import sys argv = sys.argv argv = argv[argv.index("--") + 1:] # get all args after "--" stl_in = argv obj_out = argv bpy.ops.import_mesh.stl(filepath=stl_in, axis_forward='-Z', axis_up='Y') bpy.ops.export_scene.obj(filepath=obj_out, axis_forward='-Z', axis_up='Y')
Using the cone for collisions
Now we can copy this cone into our Godot project and Godot will automatically import it for us. Since we want to use this for collision detection, but we do not want things bouncing off our firing arc, the node we need to use is an area node, which I renamed "Cone". Now, this node is going to complain that it doesn't have a collision shape, but don't give it one yet. If we drag this into our player scene, it will automatically create a visual mesh for us, which is not quite what we want, but we can use it to get to our goal. First make this a child of the area node, select it, and at the top of the viewport, select mesh and create trimesh sibling. You might be able to get away with using the convex option instead, but I found it to far too inacurrate, but your mileage may vary. You can now delete the mesh and move the trimesh collision shape to create a firing arc.
Now we want to keep track of any players inside our firing arc. While the area does provide a function to do this, it is not reliable and we should use signals as suggested by the documentation. Since the players are rigidbodies, we need to connect the body_entered and body_exited signals to the player so we can store the players in the cone in a list. The code for this is quite simple.
# player.gd var players_in_firing_arc =  func _on_player_enter_firing_arc(body): players_in_firing_arc.append(body) print(body.name) func _on_player_exit_firing_arc(body): players_in_firing_arc.erase(body) print(body.name)
You might find that this code does not work. I had to switch to GodotPhysics in the project settings under 3d under physics and update to the latest version of Godot. Doing this change breaks the resetting of the position of players when pressing play, so we need to move the position reset into the physics loop.
# player.gd/_physics_process if !was_running_plan: players_in_firing_arc = $GunContainer/Cone.get_overlapping_bodies() if using_full_plan: set_linear_velocity(initial_velocity) set_angular_velocity(initial_rotational_velocity) set_translation(initial_translation) set_rotation(initial_rotation) else: set_linear_velocity(end_linear_velocity) set_angular_velocity(end_rotational_velocity) set_translation(end_translation) set_rotation(end_rotation)
The setting of the players_in_firing_arc is to try and avoid situations where the signals might not work, namely when there is a large jump in position. Since this block comes after the part that returns if we are not running the plan, this block will only execute on the first physics frame of the plan and the using_full_plan is set when the player clicks "Play" or "Play Last".
Make the weapon do damage based on this list
We now need to update the fire method, since we no longer want to use the raycast to determine who needs to take damage. We can determine the closest enemy in the firing arc using the following code snippet. If the result is null, then there is no enemy in the firing arc.
# player.gd/_fire_gun var closest_enemy = null var distance_of_closest = -1 for player in players_in_firing_arc: if player.player_id == player_id or !player.is_alive: continue var player_distance : float = (player.translation - translation).length() if distance_of_closest == -1 or player_distance < distance_of_closest: closest_enemy = player distance_of_closest = player_distance
Now we can alter the rest of the code we wrote previously to use this result instead of the raycast result. I will show this code after I discuss how we need to do the visuals now.
Adjusting the visual laser
We want the laser to point at our target. Luckily there is a function that does just that for spatials called "look_at". This aligns your -z axis with your target, so if we make our raycast point in the -z direction and rotate our laser to be in the z direction, we should be set. Our update laser also needs to reflect the change in direction.
# player.gd func _update_laser(length : float) -> void: $GunContainer/RayCast/CSGCylinder.height = length*10 $GunContainer/RayCast/CSGCylinder.translation = Vector3(0, 0, -length*5)
This means that as long as we update the laser based of the z cast to distance and we point our raycast at the target, everything should just work. The rest of the fire function is given below using the closest enemy found in the last section.
# player.gd/_fire_gun if closest_enemy != null: $GunContainer/RayCast.look_at($GunContainer/RayCast.global_transform.origin - translation, Vector3(0,1,0)) var raycast_pos : Vector3 = $GunContainer/RayCast.global_transform.origin var target_pos : Vector3 = $GunContainer/RayCast.get_collision_point() _update_laser(raycast_pos.distance_to(target_pos)) else: $GunContainer/RayCast.rotation = Vector3.ZERO _update_laser(-$GunContainer/RayCast.cast_to.z) energy -= delta * BASE_ENERGY_USAGE if closest_enemy != null: closest_enemy.take_damage(BASE_DPS * delta)
After making these changes to the server, we should have a fully functioning weapons system with a little leniancy. You can change the tolerance of the system by changing the transform of the cone.
This game is FOSS
As of writing this, the game that I have been working on for the last 28 episodes has been released under the GPLv3 on my gitea (FOSS alternative to GitHub).