Forgive the lyrical title…
Last week, I talked about a proof of concept for a game I’m writing using Arcade and Pymunk, which involves a small rock orbiting a set of larger planets. I was able to make the rock orbit properly, while ensuring the planets stayed put where they were. The rock is given an initial impulse which I define statically, then gravity takes over.
After that entry, I made a version with multiple planets and playing around with the gravity a bit:
In the real game, the starting position of the rock is set, but the player needs to define the initial impulse. In the game which inspires this, the rock was launched using a large slingshot. The impulse was defined by how far the player pulled the rock back.
So for my next proof of concept, I need to be able to click on the rock, drag it somewhere else on screen, and release it to let it fly.
Click and Flag and Drag
Moving a sprite with the mouse in Arcade is relatively painless. If a mouse button is clicked over the sprite, set a flag. Then, when the mouse is moved and the flag is set, change the position of the sprite. When the mouse button is released, unset the flag.
Arcade provides the following event handlers for mouse events:
.on_mouse_press()
for tracking mouse clicks..on_mouse_release()
for tracking when the click is release..on_mouse_move()
to track the mouse movement.
Arcade also provides the method arcade.is_point_in_polygon()
which return True
if the provided point falls within the specified polygon. If the point is the current location of the mouse cursor, and the polygon is the hit box for the player’s rock, you can tell if the mouse was clicked over the rock.
All I needed to do was to provide a flag. Since the rock could be doing a number of things (waiting for user input, flying through the air, crashing into a planet, etc.), I needed a couple of different flags. I decided to track what the rock was doing using a simple state machine.
State Your Case
A state machine (also called a finite-state automata or FSA) is a conceptual model used in computing. You define a set of states, including a starting state and one or more ending states. You also define conditions that have to be met to move from one state to another. Each state allows or restricts certain actions, and checks for conditions to move to another state. When the machine gets to one of the end states, it has either succeeded or hit some error you can tell the user about.
Let’s look at a concrete example. Let’s say you and some friends want to drive cross country from Chicago to Los Angeles on Route 66. For simplicity, at any given time let’s say you can only be in one of five different states:
- waiting to leave Chicago. This is your start state.
- driving your vehicle. You can only drive in the driving state.
- fueling the vehicle. You can’t drive in thie state, only refuel the vehicle.
- resting. You can’t drive in this state, only rest.
- arrived in L.A. This is your end state.
You can switch states, but only when certain conditions are met:
- In the waiting state:
- Change to driving only when everyone is in the car.
- In the driving state:
- Change to fueling when the car is low on fuel.
- Change to resting when everyone is tired.
- Change to arrived when you get to Los Angeles.
- In the fueling state:
- Change to driving only when the vehicle is fully fueled up.
- In the resting state:
- Change to driving only when everyone is refreshed.
Defining each state also defines what actions you can take in each state. For example, you can’t sleep or refuel the car while you are driving. The transitions also restrict your actions, so you can’t arrive in L.A. directly after fueling. In this way, the state machine ensures you can only perform legal actions to get to your goal.
You can draw this out as well, with circles representing the states, and lines between them representing the change conditions between states:
Your trip might cycle between each state several times before you finally reach your destination. A trip excerpt might be:
- State: Waiting while everyone gets into the car. Change to Driving.
- State: Driving, drive for four hours. Notice fuel is getting low, change to Fueling.
- State: Fueling. Notice car is fueled up, change to Driving.
- State: Driving, drive for four more hours. Notice fuel is getting low, change to Fueling.
- State: Fueling. Notice car is fueled up, change to Driving.
- State: Driving, drive for two hours. Notice driver is geting tired, change to Resting.
- State: Resting, stay here for 12 hours. Notice driver is refreshed, change to Driving.
- State: Driving…
- …
- State: Driving, notice the "Welcome to Los Angeles" sign, change to Arrived.
- State: Arrived. You’re done.
State machines can be extremely powerful — in fact, most modern compilers use them in one form or another to turn your typed code in machine language goodness. Despite this power, state machines are relatively simple to setup and code properly. All you need do is define your states, know when to change to the next state, and make sure you do the right things in each state.
State of Play
So how does a state machine work in the game? I define a set of simple states, as well as some very simple transitions between them:
- Waiting for the player to start the level. This is the start state.
- Dragging the rock into position.
- Dropped when the player has released the rock.
- Flying when the rock is under gravity control.
- Crashed when the rock has hit a planet. This is an end state representing failure.
- Finish when the rock gets to the goal. This is an end state representing success.
Transitions between states are straight-forward and fairly linear:
- From the Waiting state, change to Dragging when the button is pressed on the rock.
- From the Dragging state, change to Dropped when the button is released.
- From the Dropped state, change to Flying after the initial impulse is applied.
- From the fFying state, change to Crashed if the rock collides with a planet, or Finish if the rock collides with the goal.
The game starts in the Waiting state. The player clicks on the rock, switching to the Dragging state. They can move the rock around, but when they release the mouse button, we switch to the Dropped state. The program applies the initial impulse, then switches immediately to the Flying state. In the Flying state, we calculate gravity and allow the physics engine to move the rock. We stay in this state until we either collide with a planet, or reach the goal.
In code, this might look like this:
def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
# Did the player click on the rock?
if (self.player.state == PlayerStates.WAITING and arcade.is_point_in_polygon(x, y, self.player.get_adjusted_hit_box())):
# Move to the next state.
self.player.state = PlayerStates.DRAGGING
def on_mouse_motion(self, x: float, y: float, dx: float, dy: float):
# Are we dragging the rock? If so, move the rock where the mouse is
if self.player.state == PlayerStates.DRAGGING:
self.player.set_position(x, y)
def on_mouse_release(self, x: float, y: float, button: int, modifiers: int):
# Are we dragging the rock?
if self.player.state == PlayerStates.DRAGGING:
# Set the state to dropped so we can apply the impulse
self.player.state = PlayerStates.DROPPED
def on_update(self, delta_time):
""" Movement and game logic """
if self.player.state == PlayerStates.WAITING:
# Before the level starts
pass
elif self.player.state == PlayerStates.DRAGGING:
pass
elif self.player.state == PlayerStates.DROPPED:
# Apply the initial impulse and set the player to FLYING
self.physics_engine.apply_impulse(self.player, self.initial_impulse)
self.physics_engine.set_friction(self.player, 0)
self.player.state = PlayerStates.FLYING
elif self.player.state == PlayerStates.FLYING:
# Figure out gravity
player_pos = self.physics_engine.get_physics_object(self.player).body.position
player_mass = self.physics_engine.get_physics_object(self.player).body.mass
grav = pymunk.Vec2d(0,0)
for planet in self.planets:
planet_pos = self.physics_engine.get_physics_object(planet).body.position
planet_mass = planet.mass
grav_force = G * (planet_mass * player_mass) / player_pos.get_dist_sqrd(planet_pos)
grav += grav_force * (planet_pos - player_pos).normalized()
self.physics_engine.apply_force(self.player, grav)
Notice that unless we are in the right state, we ignore a lot of things. For example, unless we are in the DRAGGING
state, we ignore the mouse movement and mouse button release events. Our .on_update()
method even checks for the various states, applying the initial impulse only when the rock is DROPPED
, and applying the gravity calculations from the last article only when it is FLYING
. Everything seems to be in place.
Except it doesn’t work. When you try to drag the rock, it keeps jumping back into place:
The reason for this behavior is subtle, but important.
Conflict of Influence
The rock the player is controlling is defined in Arcade as a sprite, and in the physics engine as a dynamic physics body. This means the physics engine will move it based on the forces which act upon it. The only proper way to move the rock is by applying forces and impulses.
However, in the .on_mouse_motion()
method, I’m trying to move the rock sprite directly by setting it’s position based on the mouse position. This is fine with Arcade, but as I make changes to the sprite, the physics engine doesn’t know about them. It assumes nothing has happened, and reasserts it’s control, putting the sprite where it knows it should be.
That’s why the code above doesn’t work. Whenever I try to move the rock sprite using the mouse, the physics engine keeps moving the rock physics body back.
It turns out the answer is relatively simple — move the physics body using the physics engine instead of the sprite. The one-line code change looks like this:
def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
# Did the player click on the rock?
if (self.player.state == PlayerStates.WAITING and arcade.is_point_in_polygon(x, y, self.player.get_adjusted_hit_box())):
# Move to the next state.
self.player.state = PlayerStates.DRAGGING
def on_mouse_motion(self, x: float, y: float, dx: float, dy: float):
# Are we dragging the rock? If so, move the rock where the mouse is
if self.player.state == PlayerStates.DRAGGING:
self.physics_engine.set_position(self.player, (x,y)) # <--- Change here
def on_mouse_release(self, x: float, y: float, button: int, modifiers: int):
# Are we dragging the rock?
if self.player.state == PlayerStates.DRAGGING:
# Set the state to dropped so we can apply the impulse
self.player.state = PlayerStates.DROPPED
Once this is done, the game works as expected:
Next time, I’ll have a catapult or slingshot or something so I can calculate and apply an impulse based on player input!