CardConquest Unity Multiplayer GameDev Blog and Tutorial #23: The Discard Pile

Disclaimer: I am neither a professional game developer nor am I a professional programmer. I doubt I am following best practices. This is just what worked for me at the time. Hopefully it works for you if you’re playing along while reading!

Version of Unity used: 2019.4.13f1 Personal

The previous posts in this series can be found under the CardConquest category on this blog. The github for the project can be found here. Zip archives for specific posts can be found here.

So far in CardConquest, when a player plays a card in a battle, that card is discarded after the battle. The player only has five cards, though, so they quickly run out of them as they play. The player also has to play a card in a battle. So, what happens after the player plays all five of their cards? Well, it’s pretty simple, actually: the discard pile will be put back into their hand and it starts over. After you play all your cards, you get them all back. The “strategy” then will be to know when to play what cards or something like that.

Anyway, time to code some stuff!

Move Cards from Discard to Hand: Server

The players’ hands and discard piles are tracked in two ways: On the server by their network IDs, and locally by their gameobjects. The server uses the lists HandNetId and DiscardPileNetId in PlayerHand.cs to track the hand/discard of each player.

The MoveCardToDiscard function in PlayerHand.cs is what moves a card’s network id from the HandNetId list to the DiscardPileNetId list, and in effect “discards” a card on the server side. This will also be where the server detects when the player has played all cards in their hand and puts all of the discard pile cards back into the player’s hand.

The last bit of code in MoveCardToDiscard is this:

if (HandNetId.Count > 0)
{
	RpcMoveCardToDiscard(cardtoDiscardNetId);
}

This checks if there are any remaining cards in the player’s hand, and if so, calls RpcMoveCardToDiscard to execute on the client.

The above code will be changed to the following:

RpcMoveCardToDiscard(cardtoDiscardNetId);
if (HandNetId.Count == 0)
{
	Debug.Log(ownerPlayerName + "'s hand is empty. Resetting their hand by add all discard pile cards back to their hand list");
	foreach (uint discardCardNetID in DiscardPileNetId)
	{
		if (!HandNetId.Contains(discardCardNetID))
			HandNetId.Add(discardCardNetID);
	}
	DiscardPileNetId.Clear();
}

First, RpcMoveCardToDiscard is always called to execute on the client. Then, if the HandNetId is empty (its count equals 0), then the server will loop through the DiscardPileNetId and add all the network ids in the discard pile to HandNetId. Then, DiscardPileNetId is cleared. This will move all of the cards in the discard pile to the hand on the server.

Move Cards from Discard to Hand: Client

On the client side, the RpcMoveCardToDiscard function in PlayerHand.cs is what moves the card gameobjects from the hand to the discard using the Hand and DiscardPile lists.

First things first, a new check will be added to RpcMoveCardToDiscard to make sure the DiscardPile list is ordered by the card power levels. This will be important later when viewing the discard pile is added to the game.

if (DiscardPile.Count > 0)
	DiscardPile = DiscardPile.OrderByDescending(o => o.GetComponent<Card>().Power).ToList();

After that is taken care of, another check will be added to see if the Hand list has a count of 0. If it does, all of the gameobjects in DiscardPile will be added back to Hand and the DiscardPile list will be cleared.

if (Hand.Count == 0)
{
	Debug.Log("RpcMoveCardToDiscard: The player's hand is now empty. Resetting their hand by putting all cards in discard into their hand.");
	foreach (GameObject cardInDiscard in DiscardPile)
	{
		if (!Hand.Contains(cardInDiscard))
			Hand.Add(cardInDiscard);
		cardInDiscard.transform.SetParent(this.transform);
		cardInDiscard.SetActive(false);
	}
	DiscardPile.Clear();
	Hand = Hand.OrderByDescending(o => o.GetComponent<Card>().Power).ToList();
}

And, that should be everything as far as adding the discarded cards back to the player’s hand goes! Now, it’s time to actually be able to view the discard pile.

Update to Show/Hide Player Hand on Screen

Actually displaying (and then later, hiding) the player’s hand is done in PlayerHand.cs in the ShowPlayerHandOnScreen and HidePlayerHandOnScreen functions. Currently, these functions only show/hide the player’s hand. They will now be updated to show either the player’s hand OR their discard pile based on a string passed to the functions as an argument.

First to be modified will be ShowPlayerHandOnScreen. The code is shown below:

public void ShowPlayerHandOnScreen(string HandOrDiscard)
{
	isPlayerViewingTheirHand = true;
	
	//Set the cards to show to either the discard or the hand?
	List<GameObject> handOrDiscard = new List<GameObject>();
	if (HandOrDiscard == "Hand")
		handOrDiscard = Hand;
	else if (HandOrDiscard == "Discard")
		handOrDiscard = DiscardPile;

	if (GameplayManager.instance.currentGamePhase.StartsWith("Choose Card"))
	{
		Vector3 cardLocation = Camera.main.transform.position;
		cardLocation.x -= 7f;
		cardLocation.z = 0f;
		Vector3 cardScale = new Vector3(1.5f, 1.5f, 0f);
		foreach (GameObject playerCard in handOrDiscard)
		{
			if (!playerCard.activeInHierarchy)
			{
				playerCard.SetActive(true);
			}
			playerCard.transform.position = cardLocation;
			playerCard.transform.localScale = cardScale;
			cardLocation.x += 3.5f;
		}
		if (GameplayManager.instance.localPlayerBattlePanel && GameplayManager.instance.opponentPlayerBattlePanel)
		{
			GameplayManager.instance.localPlayerBattlePanel.SetActive(false);
			GameplayManager.instance.opponentPlayerBattlePanel.SetActive(false);
		}
	}
	else
	{
		Vector3 cardLocation = new Vector3(-10f, 1.5f, 0f);
		Vector3 cardScale = new Vector3(1.75f, 1.75f, 0f);
		foreach (GameObject playerCard in handOrDiscard)
		{
			if (!playerCard.activeInHierarchy)
			{
				playerCard.SetActive(true);
			}
			playerCard.transform.position = cardLocation;
			playerCard.transform.localScale = cardScale;
			cardLocation.x += 4.5f;
		}
	}
	// Hide land text since it displays over cards
	GameObject landHolder = GameObject.FindGameObjectWithTag("LandHolder");
	foreach (Transform landChild in landHolder.transform)
	{
		LandScript landScript = landChild.GetComponent<LandScript>();
		landScript.HideUnitText();
	}
}

The HandOrDiscard argument tells the ShowPlayerHandOnScreen if they player is viewing their “Hand” or “Discard.” Depending on which is passed in HandOrDiscard, the Hand list or the Discardpile list will be added to handOrDiscard. Then, the ShowPlayerHandOnScreen will iterate through the list in handOrDiscard to display the cards.

Similar changes need to be made to HidePlayerHandOnScreen, shown below:

public void HidePlayerHandOnScreen(string HandOrDiscard)
{
	isPlayerViewingTheirHand = false;

	List<GameObject> handOrDiscard = new List<GameObject>();
	if (HandOrDiscard == "Hand")
		handOrDiscard = Hand;
	else if (HandOrDiscard == "Discard")
		handOrDiscard = DiscardPile;

	foreach (GameObject playerCard in handOrDiscard)
	{
		if (playerCard.activeInHierarchy)
		{
			playerCard.SetActive(false);
		}
	}
	if (GameplayManager.instance.currentGamePhase.StartsWith("Choose Card") || GameplayManager.instance.currentGamePhase == "Battle Results")
	{
		GameObject landHolder = GameObject.FindGameObjectWithTag("LandHolder");
		foreach (Transform landChild in landHolder.transform)
		{
			if (landChild.gameObject.GetComponent<NetworkIdentity>().netId == GameplayManager.instance.currentBattleSite)
			{
				LandScript landScript = landChild.GetComponent<LandScript>();
				landScript.UnHideUnitText();
			}
		}
		if (GameplayManager.instance.localPlayerBattlePanel && GameplayManager.instance.opponentPlayerBattlePanel)
		{
			GameplayManager.instance.localPlayerBattlePanel.SetActive(true);
			GameplayManager.instance.opponentPlayerBattlePanel.SetActive(true);
		}
		if (GameplayManager.instance.showingNearbyUnits)
			GameplayManager.instance.ShowUnitsOnMap();
	}
	else
	{
		GameObject landHolder = GameObject.FindGameObjectWithTag("LandHolder");
		foreach (Transform landChild in landHolder.transform)
		{
			LandScript landScript = landChild.GetComponent<LandScript>();
			landScript.UnHideUnitText();
		}
	}
}

Another important note is that if (GameplayManager.instance.currentGamePhase.StartsWith("Choose Card")) was changed to the following:

if (GameplayManager.instance.currentGamePhase.StartsWith("Choose Card") || GameplayManager.instance.currentGamePhase == "Battle Results")

That will make sure that HidePlayerHandOnScreen behaves correctly during the Battle Results phase.

There will now be a lot of errors as all the calls to HidePlayerHandOnScreen and ShowPlayerHandOnScreen aren’t passing an argument for either Hand or Discard. Currently, all the calls should just be to view the player’s hand, so you should be able to just add “Hand” in between the parentheses for every call. you could do this individually, or you can try and do a find and replace through the whole project:

This will make changes to GameplayManager.cs, OpponentHandButtonScript.cs, and MouseClickManager.cs. Make sure to save all of them after you make the changes.

The Discard Pile Button

In the game, there is a “Discard Pile” button that so far has not been used:

To make use of the button, a new function called ShowPlayerDiscardPressed will be added to GameplayManager.cs. The code is shown below:

public void ShowPlayerDiscardPressed()
    {
        bool isEscMenuOpen = false;
        try
        {
            isEscMenuOpen = EscMenuManager.instance.IsMainMenuOpen;
        }
        catch
        {
            Debug.Log("Can't access EscMenuManager");
        }

        PlayerHand localPlayerHandScript = LocalGamePlayerScript.myPlayerCardHand.GetComponent<PlayerHand>();

        if (!localPlayerHandScript.isPlayerViewingTheirHand && !isEscMenuOpen && localPlayerHandScript.DiscardPile.Count > 0)
        {

            endUnitMovementButton.SetActive(false);
            resetAllMovementButton.SetActive(false);
            showPlayerHandButton.SetActive(false);
            unitMovementNoUnitsMovedText.gameObject.SetActive(false);

            showPlayerDiscardButton.GetComponentInChildren<Text>().text = "Hide Discard";

            localPlayerHandScript.ShowPlayerHandOnScreen("Discard");
        }
        else if (localPlayerHandScript.isPlayerViewingTheirHand && !isEscMenuOpen)
        {
            endUnitMovementButton.SetActive(true);
            showPlayerHandButton.SetActive(true);

            if (!LocalGamePlayerScript.ReadyForNextPhase)
            {
                if (haveUnitsMoved)
                {
                    resetAllMovementButton.SetActive(true);
                }
                else if (!haveUnitsMoved)
                {
                    unitMovementNoUnitsMovedText.gameObject.SetActive(true);
                }
            }
            hidePlayerHandButton.SetActive(false);

            localPlayerHandScript.HidePlayerHandOnScreen("Discard");

            showPlayerDiscardButton.GetComponentInChildren<Text>().text = "Discard Pile";
        }
    }

ShowPlayerDiscardPressed works similarly to how the function to view other player’s hands works. If the player is not viewing their hand already, and importantly if there Discard Pile has at least 1 card in it (the list count is greater than 0), then it will called ShowPlayerHandOnScreen(“Discard”) in PlayerHand.cs. Then, when it is pressed and the player is viewing their hand, it will call HidePlayerHandOnScreen(“Discard”) in PlayerHand.cs.

Right now in the game, the Discard Pile button is visible when “Cards in Hand” is pressed, so ShowPlayerHandPressed will be updated to make sure the discard pile button is not active when viewing their own hand, otherwise some issues will occur.

showPlayerDiscardButton.SetActive(false);

In HidePlayerHandPressed, the showPlayerDiscardButton needs to be reactivated:

Then, throughout GameplayManager.cs, where showPlayerHandButton.GetComponentInChildren().text = "Cards in Hand"; is used to reset the text of the Cards in Hand button, you will also want to add the following to reset the discard pile button text:

showPlayerDiscardButton.GetComponentInChildren<Text>().text = "Discard Pile";

This occurs in pretty much every ActivateXUI function except for the Battle Results one.

Then, whenever you see HidePlayerHandOnScreen("Hand") called to make sure the phase doesn’t start with the cards being viewed, you will also want to add HidePlayerHandOnScreen("Discard"). This is both for when calls are made for the local playerhand and the opponent’s playerhand.

Again, this is added to every ActivateXUI function.

Save GameplayManager.cs and return to the Unity Editor. Now the ShowPlayerDiscardPressed function will need to be added to the discard button. Add the GameplayManager object to showPlayerDiscardPile’s OnClick, and then select the ShowPlayerDiscardPressed function.

Save everything, build and run, and then after your first battle the Discard Pile button should allow you to view your discard pile cards!

Viewing Other Player’s Discard Piles

Much like you can view other player’s hands, you will also be able to view their discard pile. Viewing the other player’s hand is taken care of by OpponentHandButtonScript.cs. A new function called DisplayOpponentDiscard will be added to view the player’s discard pile:

public void DisplayOpponentDiscard()
{
	bool isEscMenuOpen = false;
	try
	{
		isEscMenuOpen = EscMenuManager.instance.IsMainMenuOpen;
	}
	catch
	{
		Debug.Log("Can't access EscMenuManager");
	}
	PlayerHand myPlayerHandScript = myPlayerHand.GetComponent<PlayerHand>();
	if (!myPlayerHandScript.isPlayerViewingTheirHand && !isEscMenuOpen && myPlayerHandScript.DiscardPile.Count > 0)
	{
		GameplayManager.instance.isPlayerViewingOpponentHand = true;
		GameplayManager.instance.playerHandBeingViewed = myPlayerHand;
		this.gameObject.GetComponentInChildren<Text>().text = "Hide " + playerHandOwnerName + " Discard";
		GameplayManager.instance.ShowOpponentHandHideUI(this.gameObject);
		myPlayerHandScript.ShowPlayerHandOnScreen("Discard");
	}
	else if (myPlayerHandScript.isPlayerViewingTheirHand && !isEscMenuOpen)
	{
		GameplayManager.instance.isPlayerViewingOpponentHand = false;
		myPlayerHandScript.HidePlayerHandOnScreen("Discard");
		GameplayManager.instance.HideOpponentHandRestoreUI();
		GameplayManager.instance.playerHandBeingViewed = null;
		this.gameObject.GetComponentInChildren<Text>().text = playerHandOwnerName + " Discard";
	}
}

DisplayOpponentDiscard finds the correct PlayerHand object of the opponent, then calls ShowPlayerHandOnScreen or HidePlayerHandOnScreen on that PlayerHand object to show/hide the discard pile.

In order for that all to work, DisplayOpponentDiscard needs to know what playerhand object to look for. That is done in GameplayManager.cs when the opponent player card buttons are created. That’s done in GameplayManager.cs’s CreateGamePlayerHandButtons function. CreateGamePlayerHandButtons already sets the PlayerHand object for the gamePlayerHandButton, and will now doing it for the gamePlayerDiscardButton as well.

OpponentHandButtonScript gamePlayerDiscardButtonScript = gamePlayerDiscardButton.GetComponent<OpponentHandButtonScript>();
gamePlayerDiscardButtonScript.playerHandConnId = gamePlayerScript.ConnectionId;
gamePlayerDiscardButtonScript.playerHandOwnerName = gamePlayerScript.PlayerName;
gamePlayerDiscardButtonScript.FindOpponentHand();

Save GameplayManager.cs and OpponentHandButtonScript.cs, and now DisplayOpponentDiscard needs to be added to the gamePlayerDiscardButton prefab in the Unity Editor.

On the gamePlayerDiscardButton prefab, add the Opponent Hand Button script as a component, add it to the button’s On Click, and then add the DisplayOpponentDiscard function as the On Click function.

To make sure everything will display correctly, set the font size of gamePlayerDiscardButton’s Text child object to 25.

You should now be able to view your opponent’s discard pile!

And here’s a video showing a player playing their last card, and in the next phase, all the cards in their discard were added back to their hand!

Next Steps…

Now the main thing left for the game is to do “end of game” scenarios. That means:

  • Detecting battles on player bases
    • If the player loses a battle on their own base, they lose
  • Detecting if a player loses all their units. No more units = you lose!

Smell you later nerds!