Saturday, December 23, 2023

Play a Hand

Overview

In continuing to build out Guillotine for Board Game Arena (see the previous post), I'm looking to enable playing cards from a hand. For now, I'm going to focus on cycling through the players and collecting the cards, ignoring any of the rules around placement. Mostly because as I was putting this together I had forgotten about some of the details that were covered in the tutorial, so I want to be sure to detail those things.

Enable playing of a card

Like with almost everything in developing behavior in a game for Board Game Arena, it starts with a state. For this, I created two of them. The first, newTrick, was not strictly needed, but I know eventually I'll need to keep track of the suit led for a trick to enforce following suit and this is where it would need to be reset. Also, when adding any new state, another state needs to be updated to transition to the new one, so "gameSelection" was updated for this.
10 => [
    "name" => "gameSelection",
    "description" => clienttranslate('${actplayer} must select the game to play'),
    "descriptionmyturn" => clienttranslate('${you} must select the game to play'),
    "type" => "activeplayer",
    "args" => "argSelectGame",
    "possibleactions" => ["gameSelection"],
    "transitions" => ["startHand" => 20]
],

20 => [
    "name" => "newTrick",
    "description" => "",
    "type" => "game",
    "action" => "stNewTrick",
    "transitions" => ["playerTurn" => 21]
],

21 => [
    "name" => "playerTurn",
    "description" => clienttranslate('${actplayer} must play a card'),
    "descriptionmyturn" => clienttranslate('${you} must play a card'),
    "type" => "activeplayer",
    "args" => "argPlayerTurn",
    "possibleactions" => ["playCard"],
    "transitions" => []
],

Since "newTrick" isn't needed yet, the action backing it is just a function calling the "playerTurn" transition.
function stNewTrick() {
    $this->gamestate->nextState("playerTurn");
}

For "playerTurn", the intention for the args would be to indicate the playable cards so that some visual indicator could be given. For now, that's not critical and the argPlayerTurn function is just returning an empty array. That leaves supporting the playCard possible action which involves several steps.

Action button

Update the onUpdateActionButtons to add an action button as part of the switch statement there.
case 'playerTurn':
    this.addActionButton('btnPlayCard', _('Play card'), 'onBtnPlayCard');
    break;

Then implement the onBtnPlayCard method to pass to the back end what card was selected to be played. This is skipping some validation that should happen to make sure things don't get into a funny state - ensure only one card is selected and that the card is a valid play - the focus, for now, is on just getting the card played.
onBtnPlayCard: function() {
    const action = "playCard";
    if (!this.checkAction(action)) return;

    const selected_cards = this.playerHand.getSelectedItems();
    const card_id = selected_cards[0].id;

    this.playerHand.unselectAll();
    this.ajaxcall(
        "/" + this.game_name + "/" + this.game_name + "/" + action + ".html",
        {lock: true, card_id: card_id}, this, function (result) {}, function (is_error) {}
    );
},

Support action call

To allow the ajaxcall from the button there needs to be an action function defined in the <game_name>.action.php file. As a reminder, the first and last lines are dictated to us as required and the middle part is just about extracting the data passed in and calling a supporting function with the game logic (<game_name>.game.php file).
public function playCard() {
    self::setAjaxMode();

    $card_id = self::getArg("card_id", AT_posint, true);
    $this->game->playCard($card_id);

    self::ajaxResponse();
}

The playCard function handles moving the card to the desired location, then fires off a notification to log that to the players as well as trigger the visual update.
function playCard($card_id) {
    self::checkAction("playCard");

    $player_id = self::getActivePlayerId();

    $current_card = $this->cards->getCard($card_id);
    $this->cards->moveCard($card_id, 'cardsontable', $player_id);

    self::notifyAllPlayers('playCard', clienttranslate('${player_name} plays ${suit_displayed}${value_displayed}'), [
        'player_name' => self::getActivePlayerName(),
        'suit_displayed' => $this->suits[$current_card['type']]['name'],
        'value_displayed' => $this->values_label[$current_card['type_arg']],
        'suit' => $current_card['type'],
        'value' => $current_card['type_arg'],
        'card_id' => $card_id,
        'player_id' => $player_id
    ]);
}

I hadn't internalized the dual nature of these notifications, so I was confused when I was looking over examples that passed back things that had nothing to do with the message. Now that I understand they are serving both, it made things a lot clearer.

Update card visuals

The first step with this is to list to the "playCard" notification we generated in the last step above. This requires updating the setupNotifications function in the <game_name>.js file with the following line.
dojo.subscribe('playCard', this, "notif_playCard");

Then define the referenced "notif_playCard" function.
notif_playCard : function(notif) {
    const player_id = notif.args.player_id;
    const suit = notif.args.suit;
    const value = notif.args.value;
    const card_id = notif.args.card_id;

    dojo.place(this.format_block('jstpl_card', {
        x : this.cardwidth * (value - 2),
        y : this.cardheight * (suit - 1),
        player_id : player_id
    }), 'playertablecard_' + player_id);
 
    if (player_id != this.player_id) {
        // Some opponent played a card
        // Move card from player panel
        this.placeOnObject('cardontable_' + player_id, 'overall_player_board_' + player_id);
    } else {
        // You played a card. If it exists in your hand, move card from there and remove
        // corresponding item
        if ($('myhand_item_' + card_id)) {
            this.placeOnObject('cardontable_' + player_id, 'myhand_item_' + card_id);
            this.playerHand.removeFromStockById(card_id);
        }
    }
 
    // In any case: move it to its final destination
    this.slideToObject('cardontable_' + player_id, 'playertablecard_' + player_id).play();
}

This has some template and styling changes that need to happen to make things work and look ok. The <game_name>_<game_name>.tpl file needs to define "jstpl_card", which is placed in the script section under the "Javascript HTML templates" comment.
var jstpl_card = '<div class="card cardontable" id="cardontable_${player_id}" style="background-position:-${x}px -${y}px"></div>';

The dojo.place call that references the "jstpl_card" template also has 'playertablecard_' + player_id as its second parameter, which indicates where it should insert the template it referenced, so we need to define that in the <game_name>_<game_name>.tpl file. I made use of the layout used for the hearts tutorial game.
<div id="playertables">
    <!-- BEGIN player -->
    <div class="playertable whiteblock playertable_{DIR}">
        <div class="playertablename" style="color:#{PLAYER_COLOR}">{PLAYER_NAME}</div>
        <div class="playertablecard" id="playertablecard_{PLAYER_ID}"></div>
    </div>
    <!-- END player -->
</div>

To get the layout working I also snagged the css from the hearts tutorial. This will create four boxes for each player where their played card can be shown.
#playertables {
    position: relative;
    width: 710px;
    height: 340px;
}

.cardontable {
    position: absolute;
    width: 72px;
    height: 96px;
    background-image: url('img/cards.jpg');
}

.playertablename {
    font-weight: bold;
}

.playertable {
    position: absolute;
    text-align: center;
    width: 180px;
    height: 130px;
}

.playertable_N {
    left: 50%;
    top: 0px;
    margin-left: -90px /* half of 180 */
}

.playertable_S {
    left: 50%;
    bottom: 0px;
    margin-left: -90px /* half of 180 */
}

.playertable_W {
    left: 0px;
    top: 50%;
    margin-top: -55px /* half of 130 */
}

.playertable_E {
    right: 0px;
    top: 50%;
    margin-top: -55px /* half of 130 */
}

.playertablecard {
    display: inline-block;
    position: relative;
    margin-top: 5px;
    width: 72px;
    height: 96px;
}

.playertablename {
    font-weight: bold;
}

Note, I said can be shown. That's because while listening to the notification will remove the card from the player's hand and show the card moving to the table. We won't keep track of the cards that have been played to the table and show them. For that, we need one more piece.

Show cards on the table

Back in the "Support action call" section I mentioned how the playCard method moved the card to the desired location. Just to help connect the dots, here's the specific call I'm talking about:
$this->cards->moveCard($card_id, 'cardsontable', $player_id);

The moveCard function is part of the Deck component and the location value is just a string value that can be anything you want. So the cards that are in the "cardsontable" location need to be passed over as part of the getAllDatas so the front end can work with it.
$result['cardsontable'] = $this->cards->getCardsInLocation('cardsontable');

Then in the front end (<game_name>.js file) in the setup method we need to show the cards on the table.
for (let i in gamedatas.cardsontable) {
    const card = gamedatas.cardsontable[i];
    const suit = card.type;
    const value = card.type_arg;
    var player_id = card.location_arg;
    this.playCardOnTable(player_id, suit, value, card.id);
}

The playCardOnTable function is just the guts of the notif_playCard function extracted to a new method since the same thing was being done in both locations. That should get it so the current play can play a card to the table, but it will stay with the same player and prompt them to play another card.

Advance to the next player

For this to happen we want another state that will change the active player. Again, we're adding a state, so we need something to transition to it - we'll update "playerTurn" with a new transition.
21 => [
    "name" => "playerTurn",
    "description" => clienttranslate('${actplayer} must play a card'),
    "descriptionmyturn" => clienttranslate('${you} must play a card'),
    "type" => "activeplayer",
    "args" => "argPlayerTurn",
    "possibleactions" => ["playCard"],
    "transitions" => ["cardPlayed" => 22]
],

22 => [
    "name" => "nextPlayer",
    "description" => "",
    "type" => "game",
    "action" => "stNextPlayer",
    "updateGameProgression" => true,
    "transitions" => ["nextPlayer" => 21]
],

For the new transition to be used, the playCard function needs to be updated to use it.
$this->gamestate->nextState("cardPlayed");

The stNextPlayer function should go into the "Game state action" section and handle moving to the next player.
function stNextPlayer() {
    $player_id = self::activeNextPlayer();
    self::giveExtraTime($player_id);
    $this->gamestate->nextState("nextPlayer");
}

I saw the giveExtraTime call in the Hearts tutorial and assumed it was a good thing to do, but I haven't looked into it.

Handle finishing a trick

Now that it's advancing to the next player it needs to gather the cards when everyone has played one. Again this is going to skip some logic in the interest of keeping focused on the basic behavior. So let's not worry about who won the trick and just have someone gather them and keep going around the table playing cards. This involves updating the stNextPlayer function.
function stNextPlayer() {
    if ($this->cards->countCardInLocation('cardsontable') == 4) {
        $winning_player_id = self::activeNextPlayer();
        $this->cards->moveAllCardsInLocation('cardsontable', 'cardswon', null, $winning_player_id);

        $players = self::loadPlayersBasicInfos();
        self::notifyAllPlayers('trickWin', clienttranslate('${player_name} wins the trick'), [
            'player_id' => $winning_player_id,
            'player_name' => $players[ $winning_player_id ]['player_name']
        ]);
        self::notifyAllPlayers('giveAllCardsToPlayer','', [
            'player_id' => $winning_player_id
        ]);

        $this->gamestate->nextState("nextTrick");
    } else {
        // Not end of trick or hand, so move to the next player
        $player_id = self::activeNextPlayer();
        self::giveExtraTime($player_id);
        $this->gamestate->nextState("nextPlayer");
    }
}

Now if four cards have been played it pretends the next player won the trick and moves them to the "cardswon" location for that player. Then it sends out two notifications. The first lets all the players know about who one the trick and gives a little pause to give players a chance to see the hand. So in the <game_name>.js file an update is made to the setupNotifications function and the implementation of the function supporting the subscription to the notification.
dojo.subscribe('trickWin', this, "notif_trickWin");
this.notifqueue.setSynchronous('trickWin', 1000);

notif_trickWin : function(notif) {
    // We do nothing here (just wait so players can view the 4 cards played before they're gone).
}

The second notification is to show the gathering of the cards and make room for the next trick of cards to be played.
dojo.subscribe('giveAllCardsToPlayer', this, "notif_giveAllCardsToPlayer");

notif_giveAllCardsToPlayer : function(notif) {
    var winner_id = notif.args.player_id;
    for ( var player_id in this.gamedatas.players) {
        var anim = this.slideToObject('cardontable_' + player_id, 'overall_player_board_' + winner_id);
        dojo.connect(anim, 'onEnd', function(node) {
            dojo.destroy(node);
        });
        anim.play();
    }
},
This makes use of a few things that I haven't dug much into, but are all related to the dojo framework. I'm not going to dig further into that now.

Conclusion

Now all of the cards can be played out, but it won't wrap up the trick and deal out another hand. Seems before starting that I should fix up some of this playing and figure out who won a trick and enforce following of suit.

 

No comments: