I’m back with another tech talk about my game jam game. This post will cover the data structure we used to store our cards, and our system for implementing card actions. To recap the premise for our game, each card in your hand contains an attack part and a defense part. You as the player need tho choose whether to use any given card to attack or defend. Clearly, the data for a card in the player’s hand must have references to the data for the attack part and defense part.
Designing the Data Structure 🔗
Originally, we had two classes, AttackCardData
and DefenseCardData
, that both inherited from a base class called
CardData
. However, we quickly realized that aside from the role the card had, basically all the information in the
class was shared. The fact that a card has a name, description, rank, and icon is not dependent of whether that card is
for attack or defense. In the end, we ended up with a single CardData
that was defined as follows:
## A data structure for cards.
class_name CardData
extends Resource
@export var name: String ## The name of the card.
@export var short_name: String ## An abbreviated name.
@export_multiline var description: String ## A detailed description
@export var icon: Texture2D ## The icon for this card.
@export var rank: int ## The power rank of this card.
@export_enum("Attack", "Defense") var card_role: String ## The role of this card.
@export_file() var effect_script ## A path to the script run by this card.
@export var extra_data: Array[String] ## Extra data to be accessed by the script.
Most of the parameters are boilerplate, except for the effect_script
and extra_data
, but ignore those for now. Now
that we had the data structure for one half of a card (maybe this class should have been called HalfCardData
?), we
could easily create the data structure for the entire card, which we called DualCardData
. Again, this is pretty
simple. All it does is check to see that we indeed gave it one attack and one defense card, and that the two cards are
the same rank.
class_name DualCardData
extends Resource
@export var attack: CardData
@export var defense: CardData
@export var rank: int
func _init(att_data: CardData, def_data: CardData) -> void:
if att_data.rank != def_data.rank:
push_warning("Ranks do not match.")
if att_data.card_role != "Attack":
push_warning("Attack card does not have attack role.")
if def_data.card_role != "Defense":
push_warning("Defense card does not have defense role.")
attack = att_data
defense = def_data
rank = att_data.rank
Now that we had our data structure, all we needed to do was have some way of displaying it. We made a scene called
DualCard
, seen below on the left, that had placeholders for all the information. This included the names of both
cards, their icons, the rank, and the stats display. The script for the scene has a simple function, called
initialize()
that takes in a CardData
and populates the card appropriately.
func initialize(data: DualCardData) -> void:
card_data = data
rank_icon.texture = Util.rank_to_texture(data.rank)
attack_label.text = data.attack.name
attack_icon.texture = data.attack.icon
defense_label.text = data.defense.name
defense_icon.texture = data.defense.icon
update_icons(data.attack, $AttackStats)
update_icons(data.defense, $DefenseStats)
The Card Action 🔗
The effect_script
and extra_data
fields in a CardData
object are critical for operation. The effect script is
a path to a script that inherits from a class called CardAction
. CardAction
contains an interface that makes it easy
to add new cards with different effects. All you need to do is define two functions, can_perform()
and
perform_action()
, and the game scene handles the rest of the logic. Below is the entire base class definition.
## Script action which is performed when putting down a card
class_name CardAction
extends Node
var game: Node
var melee_units: Node
var ranged_units: Node
func set_game(game_scene: Node) -> void:
game = game_scene
melee_units = game.get_node("Units/Melee")
ranged_units = game.get_node("Units/Ranged")
## An optional function which can prevent an action from being run (and card consumed).
func can_perform(_data: CardData, _lane: int) -> bool:
return true
## The action which is performed when the card is dropped. Accepts the card data and lane
func perform_action(_data: CardData, _lane: int) -> void:
push_warning("Unimplemented action")
To see how this is implemented in practice, let’s look at the implementation of increase_health.gd
, the script that
runs when you place the ‘Fix Walls’ card on your own castle. can_perform()
makes sure that whoever placed the card
does not already have full health.
func can_perform(_data: CardData, lane: int) -> bool:
# Make sure the side isn't already at max health
var health_bar
if lane < 3: # The enemy is using this
health_bar = game.blue_castle_health_bar
else: # We're using this
health_bar = game.red_castle_health_bar
return health_bar.current_health < health_bar.max_health
perform_action()
is responsible for healing the castle, up to its maximum health. This is where the extra_data
field
comes in. extra_data
is a list of strings representing additional data used by this function. In this case, the extra
data is just the amount of HP to heal. This allows us to use the same effect script for multiple tiers of card.
func perform_action(data: CardData, lane: int) -> void:
if data.extra_data.size() != 1:
push_error("increase_health expects 1 argument of the health to increase")
return
var health_bonus := int(data.extra_data[0])
var health_bar
if lane < 3: # The enemy is using this
health_bar = game.blue_castle_health_bar
else: # We're using this
health_bar = game.red_castle_health_bar
health_bar.current_health += health_bonus
if health_bar.current_health > health_bar.max_health:
health_bar.current_health = health_bar.max_health
health_bar.update()
Handling Card Placement 🔗
Now that we had our card display and action logic, we had to find a way of actually doing whatever is on the card when
we dragged it to a square. We used the signal dropped
from the DualCard
scene to tell when the user dropped a card.
After checking to see if we dropped it in a valid location, we pass the CardData
object for either attack or defense,
depending on the location, to a function called perform_card()
. This really is the heart of the card system, so I’ll
go through it section by section.
The first thing we do is attempt to load the effect script associated with the card. If we can’t for some reason, then we terminate early.
func perform_card(data: CardData, lane: int, is_enemy := false) -> bool:
if data.effect_script == null:
push_error("CardData %s has no script!", data.name)
return false
var card_script := load(data.effect_script)
if card_script == null or not (card_script is Script):
push_error("CardData %s has invalid script!", data.name)
return false
We then create a new child node who’s sole purpose is to run the effect script. We load the card action script and attach it to the node. We also set the game so the effect script has the references it needs.
var script_node_generic = Node.new()
add_child(script_node_generic)
script_node_generic.set_script(card_script)
var script_node: CardAction = script_node_generic
script_node.set_game(self)
If this is the enemy that is placing the card, we have to translate the position to be from the enemy’s perspective.
if is_enemy:
if lane < 3:
lane += 3
else:
lane -= 3
Now that we know what the card action is, check to see if we can actually perform it. If we can, perform the action. If the action was done by the player, add the action to the dictionary of player actions. This is used to populate the enemy’s moves during the next round.
var success := script_node.can_perform(data, lane)
if success:
script_node.perform_action(data, lane)
if not is_enemy:
if not Global.card_current_moves.has(curr_round):
Global.card_current_moves[curr_round] = []
Global.card_current_moves[curr_round].append([data, lane])
Finally, clean up the script node we allocated, and return.
remove_child(script_node)
script_node.queue_free()
return success
This system has proven to be very versatile, with few changes needing to be made after release. Overall I’m happy with the way we designed this system. It does what it needs to do without much hassle, and it’s really easy to extend. I hope you enjoyed this tech talk, and I’ll be back again hopefully soon.