DOOMFISH Devlog #002


Player State Machine, Hit Flashes, and More!

Every M, T, Th, F at 7:30 pm EST I stream game development and game study at DOOMFISHdev - Twitch

Tuesday, 10/1/24

Today I was joined on stream by my friend and programmer extraordinaire Marcelino, who's working on a low-poly horror game. We took a look at overhauling the player code due to a truly impressive amount of spaghetti we found there. We implemented a state machine to have airtight state transitions and ensure that actions like attacking while dashing or inputting movement while dashing could not occur unless sanctioned, such as a DashAttack state with its own animation and functionality (like in Hades, where you can get Boons that only affect your Dash Attack and not your Attack, or add your Dash Attack to the end of your normal attack chain).

Here's how the state machine basically functions in physics process (ran every frame for physics objects, a built in function to the Godot engine):

if state != prev_state:

        prev_state = state               # this will ensure that stateLogic is only ran when the state changes, and not on multiple frames in the same state

        stateLogic(delta)                 # runs the logic for the desired state, such as dashing or attacking.

if weCanMove:

         if inputDirection != Vector.ZERO: state = States.MOVE

         else: state = States.IDLE

Note that we are determining what state to enter within physics process which is not ideal, as we want to let the function _unhandled_input or stateChange determine what state we should be entering. This will be cleaned up in a later change.

For other states, we determine what state to enter using the _unhandled_input function, which is another awesome built-in function in Godot that is only called when there is an input (via mouse or keyboard or UI) that has not yet been handled by a function:

input_direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down").normalized()           # sets the composite input vector

if !is_dashing:        # we don't want to change states if we are dashing

        move()        # will likely be changed, but right now this is not ran in the move state itself because you should be able to move while attacking.

        if !is_attacking:        # we don't want to change states if we are in an attack

                if event.is_action_pressed("action we want to take, aka attack or dash"): state = thatState.

While this can be cleaned up with helper functions, such as a canWeEnterANewState function (amazing name, I know), this pretty cleanly determines what state we should be entering. Another thing to note is state priority - since these calculations are ran on the same frame, the checks that occur later in the code will be executed last and thus become the new state. For example, we have the attack state input checked before the dash input because we want the dash to have priority over the attack. This will likely be changed, and the player will eventually have a DashAttack state, but it's important to note priority as you need to plan for the player to be spamming inputs. Having an airtight transition system will ensure that button mashing will not lead to inconsistent, random outputs.

Finally, the most common feedback I got on my first playtest was that there was not enough player feedback on hitting an enemy. Today I got started on a hit flash effect which will play when each entity enters the hurt state. It was still pretty buggy so I don't see a need to break down the code just yet, but the basic philosophy is creating a shader that applies to every pixel in the entity's sprite and colors it white when it enters the hurt state, and then deactivating that shader either after a timer or when it enters any other state.

I hope that this was an interesting read as I am still learning a lot about state machines. On Thursday we will be cleaning up this code, but I look forward to seeing the changes that we make and why.

Thursday, 10/3/24

A super productive stream! Today Marcelino and I started out by continuing to optimize the state machine. Specifically, there was an issue exiting the dash state where it would both stop you very jarringly even if you were inputting movement, and the animation would never update. The latter was as simple as implementing an animationState.travel("Move") in the MOVE section of the match statement in stateLogic, which executes every time you enter a new state. As for the former, we originally implemented a velocity = Vector.ZERO statement at the end of dash(). This was because the dash works by increasing your max movement speed by 2.5 during, and if you exited the dash at that movement speed you would be able to keep moving at that speed. Instead, we changed this to velocity = velocity / DASH_SPEED (2.5) to reset the velocity back to the max speed of movement. Note that this max speed is functionally the constant speed of the player, but we use an extremely high acceleration to get the player from a zero velocity up to max speed very quickly.

After some testing, we were really happy with the player state machine, and it feels great to be out of spaghetti land - until we add some more complex states like dashAttacks and assignable abilities :0

Next, I was able to do a bunch of small quality-of-life upgrades. I started by scrapping the previous hit flash attempt from Tuesday. What I was doing before was creating a hurt shader in the hurt() function, called when an entity entered the hurt state, and trying to turn it off when it entered any other state. Makes sense in theory, but in reality the physics process function (where state code is eventually executed) is really not the place for timer-based executions. This may not be correct but from my understanding the physics process will create a timer on the frame it runs on and then scrap it after that frame completes. Therefore, I would call hit_flash() at the same time as the hurt() function in stateLogic() which runs only when you enter a new state. That way the timer is created independently and executed independently. 

I applied this same logic to the player, except when the player has a Plant Active Bond equipped (aka a colored sword), they already have a shader applied to the player sprite. This matters because while I'm certain there are ways to stack shaders and remove them at will, I am not blessed with this knowledge yet. The process for the hit_flash() was the same: create a new shader, apply it to the sprite, wait for a timer, then delete it. The problem is that this would change the sword color shader to the hit flash, then delete it after, reverting it back to the base sword, but keeping the other properties of the Bonded sword. To fix this, I just added an if statement checking if we had a shader, and then saving the current shader to revert to after the flash. Else, I would just create a new one and revert it back to null after. This works very well and is independent of animationStates too, meaning ya boi doesn't have to create new AnimationTree nodes for every new entity added, which will save a lot of time in early development.

I also added a short invincibility period after the player gets hit to prevent taking massive damage at once. I wanted to create a little effect to display these i-frames, but it eluded me for the time being and ultimately is not important right now. The invincibility is very satisfying and important to gameplay, however. There was a small bug where dying would throw an error instead of just restarting the level because the hit_flash() would create a new shader on a null reference Sprite2D because the player was destroyed. While I could have just forced the level restart instead of killing the player, long term this is not a great solution. Instead, I added a check to the hit_flash() and other functions that execute on being hurt so that they will not execute if state = DEAD, which is connected to the signal no_health emitted by the PlayerStats autoload singleton, which stores all of the players stats and hp relevant functions.

Finally, I changed the collision layer of entities to be on their own layer so the player will not collide with them, but they will collide with each other. This is awesome because now you can weave through enemies like Hades and have completely free movement.

Next steps will be to flesh out the dash with a raycast that detects a future collision and stops the movement short so you can only dash through surfaces that have a valid point on the other side to get to, instead of just booting you out of the object if you can't stay in there. It would be cool to have some walls on the top of the room that you can dash through for a secret with some small visual indicator...

Additionally, I need to create a movingEntity subclass of Entity that holds functions for movement so they are not distributed among the other three moving subclasses, Peoples, Creatures, and Fishies. I also want to keep fleshing out the player state machine and work out all the kinks for dash attacks, interactions with the environment, and assignable abilities. These should theoretically be the only other states I really need, and I want to do them while my brain is fresh in state machine mode. I have a lot of other things I could list, but I'll start there and get to the juicy stuff later :'(

Friday, 10/4/24

We worked on some game music for a member of our discord community. He made a physics-based endless runner called CubeGame where you play as a cube in a game (wow!). You gain velocity very quick and your 3D spins create hilarious results when combined with other obstacles. He plans to expand the game to an open world in Godot. 

I also was talking with a marine biologist friend of mine about different ocean concepts like schooling behaviors, symbiotic and parasitic relationships, cool coral behaviors, and his experiences diving and seeing all of this in real time. This is fascinating to me as I've never been able to really dive, only snorkel. I ran the overarching structure of the game by him and got his okay that it was all reasonably comparable to real life oceans. Obviously, it's a mystical game, so any differences can easily explained by "It's magic so I can do whatever I want" which is quite convenient. However, it is actually quite daunting because of how fascinating and complex the ocean is without any magic involved. I'm very lucky to be able to talk to people who study it and to attempt to recreate such a beautiful part of our world.


While this week was quite light on the programming, I expect things to pick up really soon as I build my skills and start building more complex systems. However, I'm very happy with this week. Getting a player state machine working and mostly bugless is an absolute dream, and little things like hit flash and invincibility go such a long way in game feel. Unfortunately, trying to push my new build resulted in everything working except the visuals!!! Except for a label which is in the same scene as the level that is being displayed and just shows the controls in game. I cannot understand how this is being displayed and everything else isn't. I know the code is right because I can hear myself taking damage at the right rhythm based on the invincibility I installed. Hopefully I will be able to figure this out tomorrow, but at the latest I can expect a new build next weekend. Hopefully I will be able to add some more content by then as well.

Thank you for reading this far, I love you

Leave a comment

Log in with itch.io to leave a comment.