Originally posted on spakegames.com:
6/27/19:
Edit: I failed to post this yesterday as originally intended, so the timeframes are all a little off.
So, in my last post, I mentioned wanting to complete the Ghost game in Godot. Well, something came up.
I noticed this "Fighting game jam" while messing around with the new Itch game launcher. I was curious if the launcher simply allowed for making and tracking game purchases, or if it would also let you track and join jams.
The Itch game launcher lets you track and join game jams. I'm still a little psyched about it.
As you can see, I joined that Fighting game jam instead of working on the Ghost game. I did this because I'm more confident in my current Godot skills then I've ever been in my Unity skills. I did this because of the strict time limit (3 days left, gak!). I did this because I won't need to program for online play. And, most of all, I did this because I've always wanted to make some kind of fighting game.
My starting point was simple, and is based on an idea I've had for a while: Knights fighting each other in full armor with swords. The gameplay hook would be players maneuvering themselves and their swords to strike at weak points while deflecting their opponents blows with armor-shod limbs.
I realized, extremely quickly, that I don't currently have the time or the skills to make fighting in armor fun. However, I was able to modify the idea. I now have a red dude and a blue dude with sticks:
I need to get the timer, a reset condition, and round tracking done before next Saturday. I'm also really hoping to have time to add more attack animations (the basic *thwack* above isn't doing much for me).
Wish me luck!
7/2/19:
So, per my last post, I joined a week long game jam creatively titled "Fighting game jam (2?)". I found and joined the jam a full day late. Normally, I would not do or encourage this. However, in this case, I happened to have an idea a few days prior that I could easily plan out as a fighting game. For more details on all that, check out my previous post. Today's post is gonna be long enough as it is.
I was informed last week that my board game prototype, FTR, is going to be streamed on July 7th. I plan to post on Twitter and Facebook and briefly here on the day of the stream with a link to it, and then follow up with a full post the following week. I'll probably switch gears back to working on FTR as my full time project. I'm expecting a return to board game design to feel like whiplash after everything I've done and learned this past week, and it's my hope that posting a full breakdown of my jam game will lessen the effect. It probably won't.
Here's the end result of my design (the only submission to the jam, which is disappointing):
https://dgalga.itch.io/red-bop-blue
My goals for Red Bop Blue were as follows:
- Local-only 1v1.
- "Family friendly" per the jam rules.
- 2 onscreen characters composed of "programmer art" (blocks or poor sketches of characters with primitive animations).
- Avoid using paint programs (GIMP, Krita, etc) as much as possible; stick to Godot tools.
- 1 to 4 moves for each character.
- 1 stage.
- Lifebars for both characters.
- A win counter for both characters.
- All damage tied to the character's weapons.
- A way for swords to clash or block each other.
As you can see, I achieved all of this and more:
The outcome of all of my effort isn't fancy, and is very broken, and I learned alot in making it. What follows is a breakdown of what I learned with my code.
Section 1: Skeleton
My current favorite thing about Godot is the instancing and scene structure. Making, working on, and importing multiple scenes at a time is something I'm quickly getting used to. I used this approach to construct a baseline character, or a character all others will contain attributes of, with a baseline script that will extend to all other characters:
As you can see, the basic character is composed of ColorRect, KinematicBody2D, and Collision2D nodes. The ColorRect nodes, which are literally just blocks of color, were part of an effort to see how much I can prototype in Godot without needing to use a paint program.
Having the ability to speedily test ideas is something I value highly in an engine. Making assets takes time and, unless said assets are demanded by the design of the game, is something I try to avoid as much as possible in the early stages of a design.
Most engines have some simple pre-built assets. Unity has a number of 3D objects to choose from, for example. Godot is the first engine I've encountered to incorporate an easy 2D color block, and I found the ColorRect node to be easier to prototype with over Unity's 3D blocks and spheres.
Returning to the basic character, the script I used was short and sweet:
extends KinematicBody2D
const MOVE_SPEED = 150
const SWORD_ALIGN_SPEED = 50
const SWORD_LIMIT = 200
These are the values for all character move speed, character sword movement up and down speed, and the limit for how far a character's arm can move up or down.
The second basic object I worked on was the sword. I only need 1, since both characters would be using the same one. Surprisingly, the sword turned into the biggest headache of the whole project.
An issue I have with Godot is that only Sprites, or the ColorRect in this case (it's classified as a sprite), allow for pivot rotation point adjustment on import to other scenes. This is why Blue's sword rotates differently than Red's. The pivot point for the sword is at the top left of the object (that little orange cross), and I simply rotated Red and Blue's swords so that the pivot point is touching Red and Blue's arms. Red and Blue are facing opposite directions, so the rotation in animations is altered to be overhand for Red and underhand for Blue.
In the end, I decided it was more important to import the sword as an Area2D node so I could access the associated Area2D signals to speed up development. With more time, I would've had more reason to try and find a solution that allowed the swords to always rotate the same direction.
If you don't know what Godot signals are, think of them as special scripting components. They help to establish a relationship between the origin of a signal and the script you're using it in, without you needing to code in any awareness between the objects of each other. I don't know of any way to use signals between scenes. I've only ever gotten them to work between objects that are in the same scene.
The script I used for the sword was a little more complex than the base character script:
extends Area2D
var obj_name
func _on_body_entered(body): #check for any player entering damage area and pass back to gamestate to resolve
obj_name = body.get_name()
Global.gameState.resolve_dmg(obj_name)
if area.name == "SwordTemp":
Global.swords_crossed = true
Global.swords_crossed = false
The full script has connections to scripts that I haven't even mentioned yet. Global is just a storage space that all other scripts are aware of. No processing happens in Global, but scripts can make calls to Global to reference other objects or scripts that would otherwise be invisible due to the scripts/objects existing in different scenes. However, there's still enough here to detail what the sword was supposed to be doing:
- All damage was handled through sword collision. If the sword collides with a physics body (either player), it gets the name of the player and passes it back to a script that tracks the overall state of the game. The gameState script then deducts health appropriately.
- As an Area2D object, the sword itself is not a physics object. So, in order to interact with itself for sword crossing/blocking/etc events, the sword has to check for collisions with "area" nodes. The area entered and exit nodes simply check if the sword has collided with another version of itself, and then if it's stopped colliding with a version of itself.
After creating the base character and the sword, I created a scene for the main stage. The main stage is literally just a container for other objects to be imported into, and there's not much to say about it as a result.
Section 2: The Players
I didn't make the player characters the way I imagine a professional-level fighting game would make then: build 1 character off of an import of the base character scene, and then import the character twice into the main scene to serve as player 1 and player 2. I didn't believe I would be able to figure this out easily and within the time alotted, so I didn't even try.
Instead, I manually built a player 1 scene, and player 2 scene, and called them Blue and Red.
All of the scripts on Red and Blue are exactly the same save for the animations, which I couldn't find a way to copy. The only real difference between them is that I started and finished Red first.
The greyed out nodes in the image above are everything I imported from the base character scene. The additions for each character are clear: Red and Blue each have their own arm, an AnimationPlayer node, and a separate instance of the sword.
Speaking of the AnimationPlayer, guess what the 2nd biggest headache in making the game was?
Godot has built in animation system which, combined with the ColorRect nodes, saved me alot of time I would otherwise have had to spend making custom sprites. That being said, the existing animation system is not perfect.
For starters, the animation system doesn't like collision nodes. Both players have 1 leg with a collision box on it as part of a quick and dirty "get off me!" kick move. That leg, as you can see above and in the video gameplay, breaks constantly.
If you're only moving sprites around, or if you have only very basic movements/needs for the Godot animation system, it's perfect. However, if you have a sequence that requires alot of moving pieces, like this:
Anyhoo, on to the player scripts:
extends "res://Scripts/BaseChar.gd"
var velocity = Vector2()
var ori_screen_size
var keep_onscreen
onready var P2Arm = $RArm
func _ready():
ori_screen_size = get_viewport_rect().size
func _physics_process(delta):
update_move(delta)
update_sword(delta)
move_and_slide(velocity)
self.position.x = clamp(position.x,-keep_onscreen,keep_onscreen) #-220,220) these are the values I had to set to figure out how to keep the player object onscreen... weird
self.position.y = 2
if Input.is_action_pressed('ui_p2_right') and not Input.is_action_pressed("ui_p2_strikemod"):
velocity.x = MOVE_SPEED
elif Input.is_action_pressed('ui_p2_left') and not Input.is_action_pressed("ui_p2_strikemod") and not Global.swords_crossed:
velocity.x = -MOVE_SPEED
else:
velocity.x = 0
var sword_rot = P2Arm.get_rotation()
if Input.is_action_pressed("ui_p2_swordup") and sword_rot > -SWORD_LIMIT:
sword_rot = sword_rot - (SWORD_ALIGN_SPEED/2)
P2Arm.set_rotation(sword_rot)
elif Input.is_action_pressed("ui_p2_sworddown") and sword_rot < SWORD_LIMIT:
sword_rot = sword_rot + (SWORD_ALIGN_SPEED/2)
P2Arm.set_rotation(sword_rot)
if Input.is_action_pressed("ui_p2_strikemod") and Input.is_action_pressed("ui_p2_right"):
$AnimationPlayer.play("str_slash")
elif Input.is_action_pressed("ui_p2_strikemod") and Input.is_action_pressed("ui_p2_left"):
$AnimationPlayer.play("forward_stab")
elif Input.is_action_pressed("ui_p2_strikemod") and Input.is_action_pressed("ui_p2_swordup"):
$AnimationPlayer.play("kick")
elif Input.is_action_pressed("ui_p2_strikemod") and Input.is_action_pressed("ui_p2_sworddown"):
$AnimationPlayer.play("low_slash")
There's alot going on here, so I'm going to break it down by function:
- _ready(): In this function, we are getting the current screensize so I can always have the player onscreen. I was surprised at how difficult this was to figure out. Unity screen limiting can be simple X and Y values, but in Godot I have to divide by 4 to even get close to what I want.
- _physics_process(delta): This is calling my custom functions to be updated every physics frame (only on the frames when physics is processed versus normal delta-bound processing), moves the player according to passed values, continuing to ensure the player is locked onscreen, and locking down the Y value for the player. Some animations at certain angles physically move the player up and down and, as there's no way for the player to compensate, I came up with self.position.y as a quick and dirty solution.
- update_move(delta): Sets the values that move the player according to the time values passed down from _physics_process(delta). Player input is read, and you move right, left, or stop. If swords are touching, you cannot move forward (only back).
- update_sword(delta): This function rotates the player's arms up or down, and initiates attack animations if the correct movement button is pressed while the attack modifier button is held down.
All of the above is fairly standard, which was perfect for this project. Figuring out a turn-based movement system, for example, would probably not be something I could figure out in 1 week with my current skills.
Section 3: The UI
The UI system was where my existing skills were tested the most. The UI amounts to 1 entire scene built around the game camera:
What we got in this scene:
- Healthbars for Red and Blue that use a color gradient to symbolize reductions in health.
- Various Label nodes to provide text feedback to the player (number of wins, whose health is whose, a round timer).
- A timer for the round (and that will end the round if the timer runs out) and a timer to allow for enough processing time to pass when a round ends. I had some issues early on with wins being recorded incorrectly, or not be recorded at all, and through testing I found that a 1 to 2 second timer helped avoid this issue completely.
- An exit prompt. This was added when I realized I had a few hours before the game was due, and was supposed to be little more than a quick and dirty way to quit the game. Oh, it became a headache, believe me.
And now, for the piece de restroom that tied everything together, the gamestate script!
extends Area2D
var screen_size
var gameState
var blue_wins = 0
var red_wins = 0
var swords_crossed = false
Gotcha! That's the Global script! This is the gamestate script:
extends Node
onready var P1Life = $P1Healthbar
onready var P2Life = $P2Healthbar
onready var ResetTimer = $Reset
var player1_life
var player2_life
func _ready():
Global.gameState = self
EndText.visible = false
VisibleTime.text = str(update_time_track)
P1Life.max_value = player_life_start
P2Life.max_value = player_life_start
player1_life = player_life_start
player2_life = player_life_start
update_life()
$BlueWins.text = "Wins: " + str(Global.blue_wins)
$RedWins.text = " Wins: " + str(Global.red_wins)
if event.is_action_pressed("ui_cancel"):
if $ExitPrompt.visible:
$ExitPrompt.visible = false
else:
$ExitPrompt.visible = true
get_tree().paused = !get_tree().paused
func resolve_dmg(name): #since all damage is currently the same, just resolve damage all in this function
#This is how switchcase works in Godot! Adding here in case, idk, I ever want to have more characters.
match name:
"Red":
update_life()
"Blue":
player1_life -= cur_dmg
update_life()
var dual_ko_check = 0
if player1_life > 0:
P1Life.value = player1_life
else:
dual_ko_check += 1
ResetTimer.start()
ko_check(dual_ko_check)
if player2_life > 0:
P2Life.value = player2_life
else:
P2Life.value = 0
dual_ko_check += 2
ResetTimer.start()
ko_check(dual_ko_check)
match ko_var:
1:
EndText.visible = true
EndText.text = "Round over! Red wins!"
Global.red_wins += 1
2:
get_tree().paused = true
EndText.visible = true
EndText.text = "Round over! Blue wins!"
Global.blue_wins += 1
3:
get_tree().paused = true
EndText.visible = true
EndText.text = "Dual KO!" #don't set points here, because the previous switches will trigger and both players already get +1 as a result
get_tree().reload_current_scene()
update_time_track -= 1 #need a variable to increment, as it appears you cannot increment off the timer itself
if update_time_track == 0:
get_tree().paused = true
if player1_life > player2_life:
EndText.visible = true
EndText.text = "Timeout! Blue wins this round!"
Global.blue_wins += 1
ResetTimer.start()
if player2_life > player1_life:
EndText.visible = true
EndText.text = "Timeout! Red wins this round!"
Global.red_wins += 1
ResetTimer.start()
if player1_life == player2_life:
EndText.visible = true
EndText.text = "Timeout! Tie!"
ResetTimer.start()
get_tree().paused = false
reset_arena()
func _on_ExitPrompt_visibility_changed(): #this is the only thing I found to unpause when the dialogue is closed through Cancel or X
if !$ExitPrompt.visible:
get_tree().paused = false
Eyes glazing over? Don't worry, mine are alittle, too. There's alot to process here. Let me break it down:
- _ready(): First, we gotta let the Global script know who the gamestate is, so that happens here. Next is all just making sure everything is set to the default it's supposed to: Healthbars set to full, pulling the current win count from Global (Global persists between wins/restarts) and ensuring the win counts are up to date, etc.
- _input(event): Did you press the Esc key? Pause the game and pop up the quit menu. Which, turns out pausing the game is really easy (versus Unity, where I still don't know how to pause). All you needed is get_tree().paused = [true for paused or false for unpaused]. The official documentation is actually really useful here for the curious: https://docs.godotengine.org/en/3.1/tutorials/misc/pausing_games.html
- resolve_dmg(name): This function modifies healthbars by comparing the name returned from the sword script to the "Red" and "Blue" strings. This is also my first attempt at making a switchcase in Godot and, I gotta say, I vastly prefer matchcase over the C# switchcase structure. That much less muss/fuss.
- update_life(): Literally what the name implies. It updates lifebars and checks for a potential KO.
- ko_check(ko_var): It's absurdly easy to get a double KO in Red Bop Blue. This whole function exists because my existing code was not handling the result well. Red or Blue would get a point, or sometimes double points. I don't recall exactly, but I hated the result. So, I stripped my existing KO code out of update_life() and isolated it all to ko_check until the current solution was found. Currently, those who get KOs get points which carry over to later rounds. Also, 2nd match catch. I definitely like these things.
- reset_arena(): Does what it says; if someone gets a KO, or if time runs out, this function resets the entire scene back to how it was at the start of the game.
- _on_RoundTimer_timeout(): This function is actually a signal from the RoundTimer node. Timers work a little oddly in Godot, by which I mean the RoundTimer isn't actually the clock for the match. The variable update_time_track is. What RoundTimer does is, every time it expires (every second), update_time_track is decreased by 1 and then used to update the VisualTimer Label that players actually see while playing. Wacky, right? The rest of the code in this function is about handling timeouts.
- _on_Reset_timeout(): This function is another signal, this time from the Reset timer node, and exists to solve 2 problems: The game failing to update correctly when a round ends, and to unpause the game before it gets reset. I had to pause the game when someone reached 0 health, as it was possible to continue scoring while an opponent was at 0 before the scene reset, but resetting the scene doesn't unpause the game!
- _on_ExitPrompt_visibility_changed(): The final function in my code, this signal originates from the ExitPrompt, and represents the only solution I found for if a player clicks cancel or the X on the ExitPrompt AcceptDialogue node. See, AcceptDialogue nodes have easy code for if you click OK. Programming a function for when you do anything else gets alot murkier, though. So, this function checks if the ExitPrompt is hidden, but only when the visibility of the object changes, and then unpauses the game if true.
Woof, that was a lot to go over. Here's the ExitPrompt code, if anyone's curious:
extends ConfirmationDialog
func _on_ExitPrompt_confirmed():
get_tree().quit()
That's it. That's not necessarily everything I learned or made for the project, but that's all I'm going over from it. I hope it's useful to someone. I recall trying to find an example fighting game project when I was working in Unity, and never finding anything this detailed.
Next week is back to FTR and physical game design. And much, much shorter posting. Until then.
No comments:
Post a Comment