Day 3 - Casting Sprites
Today we're going to add Zelia's casting sprites to her script.
Want to start from here?
Clone or download the result of day 2 from github
Learning goals
- Determine the angle between the Zelia sprite and the mouse cursor
- Determine the angle of the
L-stick
- Using
rad_to_deg
to determing the correct casting sprite for aiming - Code refactor to tidy up code and make it more maintainable
The steps for today
- Assign casting buttons
- Fix the casting sprites for Zelia (Technical debt 1)
- Determine cast direction via mouse cursor position and L-stick axis
- Rearrange the code in
_physics_process
a little - Draw the correct casting sprites based on cast direction
- Extract some functions for less messy code (Technical debt 2)
Assign casting buttons
After day 3 Zelia will cast fireballs in all directions:
- when holding gamepad button B
, you can aim with the L-stick
- when holding the left mouse button, you can aim with the mouse cursor.
- Go to
Project > Project Settings
- Go to
Input Maps
- Choose
Add New Action
- Set the name to
Fireball button
- Assign
Left Mouse Button
toFireball button
- Assign
Joypad Button 1
toFireball button
Now to determine which of either is pressed we need to assign one to another name.
- Choose
Add New Action
- Set the name to
Left mouse button
- Assign
Left Mouse Button
to your new action namedLeft mouse button
Fix the casting sprites for Zelia
On day 1 we added one SpriteFrames
entry for all casting images.
We should have made an entry per image to cover all her angles of casting:
- Go to
FileSystem > res:// > player > player.tscn
- Go to
Scene > Player > AnimatedSprite2D
- On the bottom pane choose
casting
- Rename it to
casting_down
(click on it a second time) - Select the image casting forward
- Press
Ctrl-C
to copy it - Add a new
Animation
namedcasting_forward
- Click on the preview window
- Select it and press
Ctrl-V
to paste the image of Zelia casting forward - Repeat this process until you have 4 entries:
casting_up
,casting_diag_up
,casting_forward
andcasting_down
- Remove the images not casting down from the
casting_down
animation:
Note: the image called casting_down
will also be used for casting down diagonally.
(Her arms look really silly when pointing directly down)
Determine cast direction via mouse cursor position and L-stick axis
First let's write some code to see if we can set the angle of casting when one of the Fireball button
s is pressed.
Later on we'll pick the correct sprite, based on her angle of casting
Go to FileSystem > player > player.gd
to edit the script.
- Add a movement state for casting:
enum MovementState { IDLE, RUNNING, AIRBORNE, CASTING }
- Add a property for the direction of casting
@export var cast_angle : float
- In the
_physics_process
setcast_angle
andmovement_state
right above the jump code
if Input.is_action_pressed("Fireball button"):
movement_state = MovementState.CASTING
# base the angle of casting on the position of the mouse
# relative to Zelia or on the L-stick
if Input.is_action_pressed("Left mouse button"):
cast_angle = (get_global_mouse_position() - position).normalized().angle()
else:
cast_angle = Vector2(Input.get_joy_axis(0, JOY_AXIS_LEFT_X), Input.get_joy_axis(0, JOY_AXIS_LEFT_Y)).normalized().angle()
# Handle Jump, only when on the floor
- Test via remote debugging
This time we will monitor the exported property cast_angle
.
If you forgot how, I documented it on day 1. Remote debug see
- Run the main scene with
F5
to see if theCast Angle
property changes when either: - Pick
Remote
- Go to
root > World > Player
- Look at the inspector
Check and see if the Cast Angle
value changes when you:
- left-click the mouse somewhere in the game window
- press B
and move the L-stick
(the player wil slide around in looking idle
right now)
Rearrange the code in _physics_process
a little
NOTE This section will rearrange code to look like this, in case you get stuck:
You could also download it and skip to the next section.
Let's get started
We need to rearrange our code in the _physics_process
a little in order to achieve 2 things:
- Let Zelia flip orientation based on her casting angle
- Let Zelia stop jumping and moving on the x-axis while casting
Flipping the player left and right by aiming
Extend the code in the if
-block we just created for setting the cast_angle
to include setting the orientation
-property correctly.
if Input.is_action_pressed("Fireball button"):
## ... movement_state and cast_angle are still set here
# base her orientation on the angle of casting as well
if cast_angle > -(PI * 0.5) and cast_angle < PI * 0.5:
orientation = Orientation.RIGHT
else:
orientation = Orientation.LEFT
Test using F5
: when clicking with the mouse left of her she should now flip to look left.
Letting Zelia stop jumping and moving on the x-axis while casting
When she's casting she should not slide sideways.
We don't want her to be able to move horizontally while casting in the air (which would make her way too powerful).
Step 1, move casting code up, stop her from sliding
- First move your entire
if Input.is_action_pressed("Fireball button")
-block all the way up to belowvelocity.y += gravity * delta
. - Change
if movement_state == MovementState.AIRBORNE
intoelif movement_state == MovementState.AIRBORNE
if Input.is_action_pressed("Fireball button"):
movement_state = MovementState.CASTING
# base the angle of casting on the position of the mouse
# relative to Zelia or on the L-stick
if Input.is_action_pressed("Left mouse button"):
cast_angle = (get_global_mouse_position() - position).normalized().angle()
else:
cast_angle = Vector2(Input.get_joy_axis(0, JOY_AXIS_LEFT_X), Input.get_joy_axis(0, JOY_AXIS_LEFT_Y)).normalized().angle()
# base her orientation on the angle of casting as well
if cast_angle > -(PI * 0.5) and cast_angle < PI * 0.5:
orientation = Orientation.RIGHT
else:
orientation = Orientation.LEFT
elif movement_state == MovementState.AIRBORNE:
This stops her from sliding or moving around while in the air.
Step 2, only jump when not casting and not airborne
She can still jump while casting now, let's fix that.
Indent the if Input.is_action_just_pressed("Jump") and is_on_floor()
to make it part of the else
-case matching not casting and not being airborne
else:
# Else we are not airborne right now
if Input.is_action_pressed("Run right"):
# so we run right when run right is pressed
orientation = Orientation.RIGHT
movement_state = MovementState.RUNNING
velocity.x = speed
elif Input.is_action_pressed("Run left"):
# .. and left ...
orientation = Orientation.LEFT
movement_state = MovementState.RUNNING
velocity.x = -speed
else:
# and stand idle if no x-movement button is pressed
velocity.x = 0
movement_state = MovementState.IDLE
## This is new
# Handle Jump, only when on the floor
if Input.is_action_just_pressed("Jump"):
$JumpSound.play()
movement_state = MovementState.AIRBORNE
velocity.y = jump_speed
That stopped her from jumping while casting
Step 3, separate code for setting initial movement state from code updating positions.
This step will have no functional effect, but it will prepare us for our "readable code refactor" that comes next.
First create this new if-block
just under velocity.y += gravity * delta
:
# Set initial movement state
if Input.is_action_pressed("Fireball button"):
movement_state = MovementState.CASTING
# base the angle of casting on the position of the mouse
# relative to Zelia or on the L-stick
if Input.is_action_pressed("Left mouse button"):
cast_angle = (get_global_mouse_position() - position).normalized().angle()
else:
cast_angle = Vector2(Input.get_joy_axis(0, JOY_AXIS_LEFT_X), Input.get_joy_axis(0, JOY_AXIS_LEFT_Y)).normalized().angle()
elif is_on_floor():
movement_state = MovementState.IDLE
else:
movement_state = MovementState.AIRBORNE
Second Replace the old if Input.is_action_pressed("Fireball button")
-block with this code:
# Update movement state, velocity and orientation based on the combo of
# her current movement state and environmental factors
if movement_state == MovementState.CASTING:
# She cannot run or move on x-axis in the air while casting
velocity.x = 0
# base her orientation on the angle of casting as well
if cast_angle > -(PI * 0.5) and cast_angle < PI * 0.5:
orientation = Orientation.RIGHT
else:
orientation = Orientation.LEFT
elif movement_state == MovementState.AIRBORNE:
Your script should now look like this:
player.gd (tag = letting-zelia-stop-x-on-cast)
Draw the correct casting sprites based on cast direction
Let's make a well-named function to choose the correct sprite name to draw:
func get_casting_sprite
## Determine the casting sprite name based on decimal degrees
func get_casting_sprite(deg) -> String:
var casting_left = (deg > 120 and deg < 180) or (deg > -180 and deg < -120)
var casting_right = deg > -60 and deg < 60
var casting_up = deg > -140 and deg < -20
var casting_down = deg > 30 and deg < 150
if casting_up and (casting_right or casting_left):
return "casting_diag_up"
elif casting_down and (casting_right or casting_left):
return "casting_down"
elif casting_up:
return "casting_up"
elif casting_down:
return "casting_down"
else:
return "casting_forward"
As you can see, this function expects the cast_angle to be passed as decimal degrees
in stead of radians
.
This makes the code a little more self-documenting, at the cost of some minimal performance.
Now let's also add the case for MovementState.CASTING
to our match
-statement:
# Determine sprite based on movement state
match (movement_state):
MovementState.RUNNING:
$AnimatedSprite2D.animation = "running"
# This was added
MovementState.AIRBORNE:
$AnimatedSprite2D.animation = "jumping"
MovementState.CASTING:
# when casting invoke get_casting_sprite to set the correct
# animation name
$AnimatedSprite2D.animation = get_casting_sprite(rad_to_deg(cast_angle))
_: # MovementState.IDLE
$AnimatedSprite2D.animation = "idle"
Note the rad_to_deg
there.
Extract some functions for less messy code
Now it's really time to fix Technical debt 2, because the debt became deeper.
NOTE: if you're already familiar with code refactors or want/need a reference, you can download the final code of today here: - player.gd
And if you're already quite familiar with these types of refactor, here's a skip link to day 4: - Day 4 - Casting fireballs
The process of tyding up code
Now there must be a pattern or best practice name for this, but here's how it goes.
Spot a function with a lot of code in the function body and do: 1. For each bit of code needs comments to explain what it does, create a function named after the comments. 2. For each block of code handling a case in an if-block write a function that says what it handles 3. For each line of code longer than your coding viewport, write functions to shorten that line 4. For each complex boolean evaluation write a function with a name that says what it does 5. For each code repetition write a function to be reused
... etcetera.
This refactor only applied step 1
, 2
and 3
Spot a big function and apply the steps!
Let's rewrite the above code again.
We spot that _physics_process
is now almost 100 lines long!
Step 1
- code that needs comments:
# Handle casting
if Input.is_action_pressed("Fireball button"):
movement_state = MovementState.CASTING
# base the angle of casting on the position of the mouse
# relative to Zelia or on the L-stick
if Input.is_action_pressed("Left mouse button"):
cast_angle = (get_global_mouse_position() - position).normalized().angle()
else:
cast_angle = Vector2(Input.get_joy_axis(0, JOY_AXIS_LEFT_X), Input.get_joy_axis(0, JOY_AXIS_LEFT_Y)).normalized().angle()
elif is_on_floor():
movement_state = MovementState.IDLE
else:
movement_state = MovementState.AIRBORNE
Is moved to a separate function named set_movement_state
.
# Set initial movement state
func set_movement_state():
if Input.is_action_pressed("Fireball button"):
movement_state = MovementState.CASTING
# base the angle of casting on the position of the mouse
# relative to Zelia or on the L-stick
if Input.is_action_pressed("Left mouse button"):
cast_angle = (get_global_mouse_position() - position).normalized().angle()
else:
cast_angle = Vector2(Input.get_joy_axis(0, JOY_AXIS_LEFT_X), Input.get_joy_axis(0, JOY_AXIS_LEFT_Y)).normalized().angle()
elif is_on_floor():
movement_state = MovementState.IDLE
else:
movement_state = MovementState.AIRBORNE
Step 1
can be applied again to this new function:
# base the angle of casting on the position of the mouse relative to Zelia
func set_cast_angle():
if Input.is_action_pressed("Left mouse button"):
cast_angle = (get_global_mouse_position() - position).normalized().angle()
else:
cast_angle = Vector2(Input.get_joy_axis(0, JOY_AXIS_LEFT_X), Input.get_joy_axis(0, JOY_AXIS_LEFT_Y)).normalized().angle()
# Set initial movement state
func set_movement_state():
if Input.is_action_pressed("Fireball button"):
movement_state = MovementState.CASTING
set_cast_angle()
elif is_on_floor():
movement_state = MovementState.IDLE
else:
movement_state = MovementState.AIRBORNE
Now both step 2
and 3
still apply to this new function:
# Vector of L-stick
func get_l_stick_axis_vec() -> Vector2:
return Vector2(
Input.get_joy_axis(0, JOY_AXIS_LEFT_X),
Input.get_joy_axis(0, JOY_AXIS_LEFT_Y)
)
# Vector from player to mouse position
func get_mouse_vec_to_player() -> Vector2:
return get_global_mouse_position() - position
# base the angle of casting on the position of the mouse relative to Zelia
func set_cast_angle():
if Input.is_action_pressed("Left mouse button"):
cast_angle = get_mouse_vec_to_player().normalized().angle()
else:
cast_angle = get_l_stick_axis_vec().normalized().angle()
Rinse and repeat!
Now we spot this elaborate comment:
"Update movement state, velocity and orientation based on the combo of her current movement state and environmental factors"
Applying the refactor steps listed previously do:
1. Create a function handle_movement_state
2. Cut+paste all the code under the long comment up to above the match
call into handle_movement_state
3. Call handle_movement_state()
after set_movement_state()
4. Create a func handle_casting
5. Cut+paste all the code within the block if movement_state == MovementState.CASTING:
into it
6. Call handle_casting()
in this if block
7. Do the same for handle_airborne()
under elif movement_state == MovementState.AIRBORNE:
8. And make 2 functions to be called under else:
- handle_running()
and handle_jumping()
Your final func handle_movement_state()
should now look like this.
# Main movement state handler entry point
func handle_movement_state():
if movement_state == MovementState.CASTING:
handle_casting()
elif movement_state == MovementState.AIRBORNE:
handle_airborne()
else:
handle_running()
handle_jumping()
Just 2 more funcs!
- Create the function
set_current_sprite
- Put the entire block of
match (movement_state):
in it - Invoke it under
handle_movement_state()
And :
- Create the function
flip_current_sprite
- Put the entire if-else block of
if orientation == Orientation.LEFT:
in it - Invoke it under
set_current_sprite()
All tidied up
Your _physics_process
should now look like this:
# Changed _process to _physics_process
func _physics_process(delta):
# Apply the gravity.
velocity.y += gravity * delta
# Set, and handle movement state
set_movement_state()
handle_movement_state()
# Set the correct sprite based on movement state
set_current_sprite()
# Determine sprite-flip based on orientation
flip_current_sprite()
# Apply 2d physics engine's movement
move_and_slide()
And everything should still work.
Your entire player.gd
script should look like this:
player.gd
- on github commit: "use my own tutorial for day-3 part 1."
Technical debt 5
Two observation on remaining technical debt:
- Our comments probably do not conform to documentation guidelines
- Some coding conventions might not comply either
Let's park that and go right on ahead to: