admoore.xyz

Siege For Yourself Tech Talk #2 - Card Structure

Tags: game_dev godot

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.