Showing posts with label BoardGameArena. Show all posts
Showing posts with label BoardGameArena. Show all posts

Tuesday, January 16, 2024

Enable Scoring of Dominoes

 

Overview

In the last bit of work, I enabled the basic playing of Dominoes and the placement of cards. However, as noted, there were things to finish before the game could be finished. I'll take these on in order:
  • When an Ace is played the player can play as many cards as they want to - as long as they're valid - before passing.
  • Need to end the game, which involves scoring it.
  • Need to tighten up the passing and at least know if the player passed when they could've played.

Allow the player playing an Ace to keep playing until they pass

My thinking for this is to add a new game state variable to keep track that an ace has been played in the playCard function. That variable can be checked when advancing to the next player and skip doing that when it's true. Finally, in the pass action, it will be cleared to allow the game to continue to the next player again. There's a final catch to this, when the Ace is a Spinner it loses the keep-playing ability, but that's a simple enough check.

Update the __construct function to add a new game state label for the ACE_PLAYED label.
self::initGameStateLabels([
    DEALER => 10,
    SELECTED_GAME => 11,
    TRICK_SUIT => 12,
    SPINNER => 13,
    ACE_PLAYED => 14,
]);

In the playCard function add a check to record if an Ace was played only if the game is Dominoes.
if (($current_card['type_arg'] == ACE) && (self::getGameStateValue(SPINNER) != ACE)) {
    self::setGameStateValue(ACE_PLAYED, 1);
}

Update the stDominoesNextPlayer function to skip advancing to the next player if an Ace was played.
function stDominoesNextPlayer() {
    if (!self::getGameStateValue(ACE_PLAYED)) {
        $player_id = self::activeNextPlayer();
        self::giveExtraTime($player_id);
    }

    $this->gamestate->nextState("nextPlayer");
}

Finally, the update to remove the indication that an Ace was played.
self::setGameStateValue(ACE_PLAYED, 0);

After trying that out, I realized it might be good to change the message using the action to indicate they are working through an Ace to give players an additional clue as to why a player is getting repeated turns in a row. This involved updating the argDominoesPlayerTurn function.
$play_message = clienttranslate('a card or pass');
if (self::getGameStateValue(ACE_PLAYED)) {
    $play_message = $play_message . clienttranslate(' (using Ace)');
}
return [
    'play_message' => $play_message,
    'passable' => true,
];

Enable scoring the game

The game of Dominoes ends when the first two players run out of cards. The first player gets -30 and the second gets -10. I created those `Scorer` classes for the other games, but I'm not sure it's worth trying to make this fit into the mold of that interface since that's not a good way to keep track of who went out first. Instead, I'm thinking of using some more game state variables that can be set in the stDominoesNextPlayer function. (Don't forget the need to define the label in the __constructor.)
function stDominoesNextPlayer() {
    $end_hand = false;

    $hand = $this->cards->getCardsInLocation(HAND, $player_id);
    if (count($hand) === 0) {
        if (self::getGameStateValue(OUT_FIRST)) {
            self::setGameStateValue(OUT_SECOND, $player_id);
            $end_hand = true;
        } else {
            self::setGameStateValue(OUT_FIRST, $player_id);
        }
    }

    if ($end_hand) {
        $this->gamestate->nextState("endHand");
    } else {
        if (!self::getGameStateValue(ACE_PLAYED)) {
            $player_id = self::activeNextPlayer();
            self::giveExtraTime($player_id);
        }

        $this->gamestate->nextState("nextPlayer");
    }
}

After writing that up I realized the next state is endHand from the original flow, which is where scoring is handled. So I either need to implement a DominoesScorer, create a different state for ending Dominoes, or hack the stEndHand function to do something a little different for Dominoes.

I'm going to create a separate state for Dominoes end hand because that follows the pattern I've used with most of the other parts of this. (Even though I'm thinking I should take another look and maybe bring it back together.) So update the endHand transitions in the dominoesNextPlayer state to point to this new state.
43 => [
    "name" => "dominoesEndHand",
    "description" => "",
    "type" => "game",
    "action" => "stDominoesEndHand",
    "transitions" => ["nextHand" => 2]
],

I extracted the main part of the stEndHand function so I could reuse it.
function updateScores($player_to_points) {
    foreach ($player_to_points as $player_id => $points) {
        $sql = "UPDATE player SET player_score=player_score+$points WHERE player_id='$player_id'";
        self::DbQuery($sql);
        self::notifyAllPlayers("points", clienttranslate('${player_name} gained ${points} points'), [
            'player_name' => $players[$player_id]['player_name'],
            'points' => $points,
            'player_id' => $player_id,
        ]);
    }

    $new_scores = self::getCollectionFromDb("SELECT player_id, player_score FROM player", true);
    self::notifyAllPlayers("newScores", '', ['newScores' => $new_scores]);

    $this->gamestate->nextState("nextHand");
}

Then call it from the stEndHand and stDominoesEndHand functions.
function stDominoesEndHand() {
    $players = self::loadPlayersBasicInfos();
    $player_to_points = [];
    foreach (array_keys($players) as $player_id) {
        $player_to_points[$player_id] = 0;
    }

    $player_to_points[self::getGameStateValue(OUT_FIRST)] = -30;
    $player_to_points[self::getGameStateValue(OUT_SECOND)] = -10;

    $this->updateScores($player_to_points);
}

Because we set some game state variables to keep track of things and there are a bunch of cards shown on the table, we need to do a little cleanup.

In the stNewHand function, we were already resetting the game state tracking the selected game, so it seemed appropriate to put all of it together into a separate function.
function resetTrackingValues() {
    self::setGameStateValue(SELECTED_GAME, 0);
 
    // Dominoes
    self::setGameStateValue(OUT_FIRST, 0);
    self::setGameStateValue(OUT_SECOND, 0);
    self::setGameStateValue(SPINNER, 0);
}

To clean up the cards shown on the table I opted to add a notification to the stDominoesEndHand function.
self::notifyAllPlayers('cleanUp', '', []);

Then subscribe to that on the front end and create the supporting function that just slides and destroys all the cards from the view.
notif_cleanUp: function(notif) {
    document.querySelectorAll('cardontable')
        .forEach(e => this.slideToObjectAndDestroy(e, 'playertables'));
},

Know if a player can pass

When the player goes to take their turn we can compare what they have in their hand to what's on the board to determine if they have any playable cards. I started by focusing on determining which cards would be playable and created a function that accepted the cards in the player's hand and the cards that were on the table.
function getPlayableCardsForDominoes($hand, $played_cards) {
    // Nothing played then everything is possible
    if (count($played_cards) === 0) return array_keys($hand);

    // Initialize possible cards to the spinners.
    $spinner_value = self::getGameStateValue(SPINNER);
    $possible_cards = [];
    foreach ($this->suits as $suit_id => $suit) {
        $possible_cards[$suit_id]['high'] = $spinner_value;
        $possible_cards[$suit_id]['low'] = $spinner_value;
    }

    // Find possible cards one higher and one lower than those played.
    foreach ($played_cards as $card) {
        $suit = $card['type'];
        $value = $card['type_arg'];
        $high_value = (int)$value + 1;
        $low_value = (int)$value - 1;
        if ($possible_cards[$suit]['high'] < $high_value) {
            $possible_cards[$suit]['high'] = $high_value;
        }
        if ($possible_cards[$suit]['low'] > $low_value) {
            $possible_cards[$suit]['low'] = $low_value;
        }
    }

    // Find cards in the player's hand that match the possible cards.
    $playable_card_ids = [];
    foreach ($hand as $card) {
        $suit = $card['type'];
        $value = $card['type_arg'];
        if (($value == $possible_cards[$suit]['high']) ||
            ($value == $possible_cards[$suit]['low']) ||
            ($value == $spinner_value))
        {
            $playable_card_ids[] = $card['id'];
        }
    }

    return $playable_card_ids;
}

Then use this function in the argDominoesPlayerTurn function. This created some significant changes to this function, so here's how it ended up.
function argDominoesPlayerTurn() {
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    $hand = $this->cards->getCardsInLocation(HAND, self::getActivePlayerId());

    if (count($cards_in_hands) == 32) {
        return [
            'play_message' => clienttranslate('play the spinner'),
            'passable' => false,
            '_private' => ['active' => ['playable_cards' => array_keys($hand)]]
        ];
    }

    $cards_on_table = $this->cards->getCardsInLocation(CARDS_ON_TABLE);
    $playable_card_ids = $this->getPlayableCardsForDominoes($hand, $cards_on_table);

    if (count($playable_card_ids) === 0) {
        // the player must pass
        return [
            'play_message' => clienttranslate('pass'),
            'passable' => true,
            '_private' => ['active' => ['playable_cards' => $playable_card_ids]]
        ];
    }

    // the player must play a card
    $play_message = clienttranslate('play a card');
    $passable = false;

    if (self::getGameStateValue(ACE_PLAYED)) {
        $play_message = $play_message . clienttranslate(' or pass (using Ace)');
        $passable = true;
    }

    return [
        'play_message' => $play_message,
        'passable' => $passable,
        '_private' => ['active' => ['playable_cards' => $playable_card_ids]]
    ];
}

The big change is if there are no playable cards, then the player must pass, which is true even if they played an Ace. If they have cards to play, then it's doing the same thing it was before - checking if they played an Ace and showing the appropriate message. Additionally, the playable_cards are being passed to the front end so that it can highlight the playable cards. Since all of the data returned from "arg" functions is public by default there's a special "_private" label to ensure it is only available to, in this case, the active player. The highlighting is done in the onEnteringState function.
case 'dominoesPlayerTurn':
    if (this.isCurrentPlayerActive()) {
        const playable_cards = args.args._private.playable_cards;
        const all_cards = this.playerHand.getAllItems();
        for (let i in all_cards) {
            if (playable_cards.includes(all_cards[i].id)) {
                document.getElementById('myhand_item_' + all_cards[i].id).classList.add('playable');
            }
        }
    }
    break;

Add the corresponding styling change.
.playable {
    border: solid green;
}

Some validation could be added to the front end to ensure that a playable card is selected, but that's a nice to have that I might do later since the back end already has that validation.

Fix an issue with a player going out twice

Whoops! Thankfully, caught an issue while I was testing it. If a player goes out and the game gets back around to them, it prompts them to play or pass. Then when they pass it treats them as going out a second time and ends the game.

This required some changes to the stDominoesNextPlayer function. The first was to check if the player was the first out before worrying if they had no cards in their hand. The next change was to skip them when making them an active player. In reality, the second part of that would be sufficient since they wouldn't be made the active player, but I'm okay with extra code to make me feel better about it.

Conclusion

Looks like Dominoes is ready to be played and I'm pretty psyched about how it came together. The last thing to do for the game to be able to be completely played is the scoring for the game Guillotine. Then I think I can get into the stats to record for this game. For instance, we've been curious about how much a player scores on each other player's calls. I suspect there are some bugs and other things that will come up, but it's getting close to being complete.

Thursday, January 11, 2024

Change How Cards are Played for Dominoes

Overview

When we last saw our hero game being developed it was just starting to handle Dominoes - wiring up some of the plumbing and being able to determine if a played card is valid. Now I want to make it look more like how it would in person, by showing the played cards in the center of the table and laid in order by suit.

Layout cards in the center of the table

The first step towards this is to create a place for the cards to go by modifying the <game_name>_<game_name>.tpl file.
<div id="playertables" class="whiteblock">
    <!-- BEGIN player -->
    <div class="playertable playertable_{DIR}">
        <div class="playertablename" style="color:#{PLAYER_COLOR}">{PLAYER_NAME}</div>
        <div id="dealer_p{PLAYER_ID}" class="playertableselectedgame">{SELECTED_GAME}</div>
        <div id="playertablecard_{PLAYER_ID}" class="playertablecard"></div>
    </div>
    <!-- END player -->
    <div id="dominoesplayarea"></div>
</div>

<div id="myhand_wrap" class="whiteblock">
    <h3>{MY_HAND}</h3>
    <div id="myhand">
    </div>
</div>

<script type="text/javascript">

// Javascript HTML templates
var jstpl_card = '<div class="card cardontable" id="cardontable_${card_id}" style="background-position:-${x}px -${y}px"></div>';
var jstpl_game_display = '<div id="glt_game_${game_type}_${player_id}" class="game_display game_display_${game_type} ${played}">${game_abbr}</div>';
</script>

The changes here are moving the "whiteblock" class from the inner div beginning the player to the playertables div and adding the dominoesplayarea div. The last change is a change to the id attribute for the jstpl_card template. Previously it was cardontable_${player_id}. Unfortunately, when the same player puts multiple cards on the table the IDs collide and the behavior gets a little funny when you try moving them around. Fortunately, nothing was looking for them based on the player so the change didn't impact much other than swapping out the player_id for card_id when building the template.

Then the styling needs to be updated to allow for cards to be put in this newly created div.
#dominoesplayarea {
    position: relative;
    width: 440px;
    height: 300px;
    top: 140px;
    left: 180px;
}

#playertables {
    position: relative;
    width: 800px;
    height: 580px;
}

This makes the playertables div take up enough space to allow for the cards to be played and puts the dominoesplayarea tucked inside of the area the player puts their cards during the other games.

Okay, let's actually put a card there

When a card is played the back end sends a playCard notification to the front end that currently makes the card visible under the player's name. I could just modify that to be aware of what game was being played, but I opted to create a different one to try to keep the understanding of what the game is on the back end. So the playCard function in the back end now looks like this.
function playCard($card_id) {
    self::checkAction("playCard");

    $player_id = self::getActivePlayerId();
    $current_card = $this->cards->getCard($card_id);

    $selected_game_id = self::getGameStateValue(SELECTED_GAME);
    if ($this->games[$selected_game_id]['type'] == "dominoes") {
        $this->checkPlayableCardForDominoes($current_card);
        $this->cards->moveCard($card_id, CARDS_ON_TABLE, $player_id);

        if (!self::getGameStateValue(SPINNER)) {
            self::setGameStateValue(SPINNER, $current_card['type_arg']);
        }

        self::notifyAllPlayers(
            'playCardForDominoes',
            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,
                'spinner' => self::getGameStateValue(SPINNER)
            ]
        );

        $this->gamestate->nextState("turnTaken");
    } else {
        $this->checkPlayableCard($player_id, $current_card);
        $this->cards->moveCard($card_id, CARDS_ON_TABLE, $player_id);
 
        if (!self::getGameStateValue(TRICK_SUIT)) {
            self::setGameStateValue(TRICK_SUIT, $current_card['type']);
        }
 
        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
            ]
        );
 
        $this->gamestate->nextState("cardPlayed");
    }
}

I realized while developing the front end that it needed to know what the spinner was to help with placing cards appropriately. (The spinner is rotated perpendicular to the other cards so players don't forget what rank it is.) Now the front end needs to handle this notification. Reminder - that involves updating the setupNotifications function to subscribe to it and indicate the function to call. I also called setSynchronous for it with a 100 delay, just like for the playCard notification. Here's the notif_playCardForDominoes function.
notif_playCardForDominoes : function(notif) {
     this.playCardInCenter(
        notif.args.player_id,
        notif.args.suit,
        notif.args.value,
        notif.args.card_id,
        notif.args.spinner
    );
},

Yeah - not too exciting, since the same logic will need to be called in the setup function, but we'll get to that after the real work is shown.
playCardInCenter : function(player_id, suit, value, card_id, spinner) {
    dojo.place(this.format_block('jstpl_card', {
        x : this.cardwidth * (value - 2),
        y : this.cardheight * (suit - 1),
        card_id : card_id
    }), 'dominoesplayarea');
 
    if (player_id != this.player_id) {
        // Some opponent played a card
        // Move card from player panel
        this.placeOnObject('cardontable_' + card_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_' + card_id, 'myhand_item_' + card_id);
            this.playerHand.removeFromStockById(card_id);
        }
    }

    dojo.style('cardontable_' + card_id, 'z-index', value);
    const x_pos = 20 + (suit-1) * 110;
    const y_pos = 200 - ((value-7) * 20) - (Number(value) > Number(spinner) ? 30 : 0);
    this.slideToObjectPos('cardontable_' + card_id, 'dominoesplayarea', x_pos, y_pos).play();

    if (value === spinner) {
        this.rotateTo('cardontable_' + card_id, 90);
    }
},

This is very similar to the playCardOnTable function that handles other games, but the jstpl_card template is put into a different div, and the new element for the template is slid to a more specific spot. The z-index is used to avoid cards going hiding on a refresh since they are placed overlapping each other. Finally, if it's a spinner rotate it.

Now we need to update the setup function so when someone reloads the page it won't mess with things.
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;

    if (gamedatas.selected_game_type === 'dominoes') {
        this.playCardInCenter(player_id, suit, value, card.id, gamedatas.spinner);
    } else {
        this.playCardOnTable(player_id, suit, value, card.id);
    }
}

With this function being aware of the selected game it makes me seriously consider the benefit of using a different notification. I'm going to stick with it for now since it's working, but might revisit that choice after Dominoes is supported.

Conclusion

Almost there! I'm psyched with how the cards are showing up for Dominoes and the game can mostly be played through. Just a couple of remaining details.
  • When an Ace is played the player can play as many cards as they want to - as long as they're valid - before passing.
  • Need to end the game, which involves scoring it.
  • Need to tighten up the passing and at least know if the player passed when they could've played.

Start Handling Dominoes

 

Overview

Now that the basics of handling the trick-taking games in Guillotine are done, it's time to shake it up and look at Dominoes. This does a couple of different things than any of the other games. The gist of the game is that you're trying to be the first or second player to play all of their cards. The dealer picks a "spinner" (which is their first card played) and cards of that value are the only ones that can be played from other suits. Players must play a card if they can or pass. A player can only play a card if its value is adjacent to an already played card or one of the spinners.

Given these differences, I expect some things will need to change and we'll see if it is just an alternate path or if it affects all of the games.

Change card sorting order

Unlike the other games, the 10s in Dominoes are in their usual place between 9 and Jack instead of between the King and Ace. This is handled in the front end by the weight specified when adding items. I updated it to recognize the selected game. Here's where the weight is specific in the setup function.
for (var suit = 1; suit <= 4; suit++) {
    for (var value = 7; value <= 14; value++) {
        const card_type_id = this.getCardUniqueId(suit, value);
        const card_weight = this.getCardWeight(suit, value, gamedatas.selected_game_type);
       
        this.playerHand.addItemType(
            card_type_id, card_weight, g_gamethemeurl + 'img/cards.jpg', card_type_id);
    }
}

The only change here is passing gamedatas.selected_game_type to getCardWeight. getCardWeight was updated to return early if the selected_game_type was 'dominoes'.
getCardWeight: function(suit, value, selected_game_type) {
    var base_weight = this.getCardUniqueId(suit, value);

    if (selected_game_type === 'dominoes') {
        return base_weight;
    }

    if (value == 10) {
        return base_weight + 4;
    } else if (value == 14) {
        return base_weight + 1;
    } else {
        return base_weight;
    }
},

However, gamedatas wasn't being passed a selected_game_type, just a selected_game which is the translated name of the game. So this required a small change to the getAllDatas function in the <game_name>.game.php file to get the type of the selected game and pass that along.

This worked like a champ but required a refresh to see the changes. This would be better if when the game was selected the sorting was updated. So back in the front end I changed the notif_gameSelection function to call another function to do the sorting change.
notif_gameSelection : function(notif) {
    document.getElementById('dealer_p' + notif.args.dealer_id).innerHTML = notif.args.game_name;
    document.getElementById('glt_game_' + notif.args.game_type + '_' + notif.args.dealer_id)
        .classList.add('played');

    this.updateCardSorting(notif.args.game_type);
},

I thought of adding a check to only update the sorting when Dominoes was called, but then I'd need to remember to change the sort at the end of Dominoes. Instead, I opted to always call it figuring that it wasn't an expensive process. Here's the updateCardSorting function.
updateCardSorting : function(selected_game_type) {
    const new_card_weights = [];
    for (var suit = 1; suit <= 4; suit++) {
        const card_type_id = this.getCardUniqueId(suit, 10);
        const card_weight = this.getCardWeight(suit, 10, selected_game_type);
        new_card_weights[card_type_id] = card_weight;
    }

    this.playerHand.changeItemsWeight(new_card_weights);
},

Implementing this caused me some pain mostly because the documentation for the changeItemsWeight function suggested an associative array, which I found doesn't exist in JavaScript. Though specifying obj[key] = value effectively does the same thing, it's just that obj is not technically an array. So I circled around this doing more complicated things than I needed to, before finally taking another look at things that told me JavaScript didn't have an associative array.

Thankfully, with this in place when Dominioes is selected you see the cards shift to their new sorting right away.

Prompt the dealer to select a spinner

This could just be another prompt to play a card, but the first card played represents the spinner and it seems something important to call out to the player. Additionally, I opted to use a separate state to drive this. There's a good chunk of logic that is shared / duplicated between non-Dominoes games and Dominoes, but I thought keeping it separate and extracting functions to share the logic would be a good way to go.

So let's start with the new states.
41 => [
    "name" => "dominoesPlayerTurn",
    "description" => clienttranslate('${actplayer} must play ${play_message}'),
    "descriptionmyturn" => clienttranslate('${you} must play ${play_message}'),
    "type" => "activeplayer",
    "args" => "argDominoesPlayerTurn",
    "possibleactions" => ["playCard", "pass"],
    "transitions" => ["turnTaken" => 42]
],

42 => [
    "name" => "dominioesNextPlayer",
    "description" => "",
    "type" => "game",
    "action" => "stDominoesNextPlayer",
    "updateGameProgression" => true,
    "transitions" => ["nextPlayer" => 41, "endHand" => 30]
],

To get into these states I updated the "gameSelection" state to have an additional transition of "dominoesSelectSpinner" => 41. Which had a change to the gameSelection function to move to that state.
if ($selected_game == 'dominoes') {
    $this->gamestate->nextState("dominoesSelectSpinner");
} else {
    $this->gamestate->nextState("startHand");
}

Notice that in the "dominoesPlayerTurn" state the description property has a message with "must play ${play_message}". The value for that variable comes from the args function, argDominoesPlayerTurn, which currently looks like this.
function argDominoesPlayerTurn() {
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    if (count($cards_in_hands) == 32) {
        return [
            'play_message' => clienttranslate('the spinner')
        ];
    }
 
    return [
        'play_message' => clienttranslate('a card or pass')
    ];
}

I can see a future where this method returns the playable cards, then, instead of having a Pass button show up all the time, it can only be there when the player must pass or it can auto-pass. (Though I fear that might be confusing, but we'll see.)

Handle playing cards

There are a lot of changes that need to happen for these cards to be played appropriately, so let's break things into smaller chunks and build it up.

Get the card played

Given the listener that was created to handle playing cards without the need to click an action button, clicking a card tried playing it. However, it fails because the transition from the playCard action, "cardPlayed", wasn't used in the "dominoesPlayerTurn" state. Since the player could pass the transition name of "turnTaken" seems more appropriate. Additionally, there are other changes to support Dominoes, so it's a small thing to use a different transition. To get the card playing I just updated the function to use a different transition.
$selected_game_id = self::getGameStateValue(SELECTED_GAME);
if ($this->games[$selected_game_id]['type'] == "dominoes") {
    $this->gamestate->nextState("turnTaken");
} else {
    $this->gamestate->nextState("cardPlayed");
}

Ensure the card is valid to play

The first card the dealer plays can be anything and is called the spinner. After that, players need to play a card that is one more or one less in value than a card on the table or a spinner from another suit.
function checkPlayableCardForDominoes($current_card) {
    $cards_on_table = $this->cards->getCardsInLocation(CARDS_ON_TABLE);

    // This will be the first card on the table, so we're good
    if (count($cards_on_table) == 0) return;
    // The card is a spinner, so it's good to play
    if (self::getGameStateValue(SPINNER) == $current_card['type_arg']) return;
 
    $cards_of_played_suit = array_filter($cards_on_table, function($card) use (&$current_card) {
        return $card['type'] == $current_card['type'];
    });

    if (count($cards_of_played_suit) == 0) {
     throw new BgaUserException(self::_(
        "Your card cannot be played because the spinner for the suit hasn't been played yet"));
    }

    $played_card_values = array_column($cards_of_played_suit, null, 'type_arg');
    if (!($played_card_values[$current_card['type_arg'] - 1] ?? false) &&
        !($played_card_values[$current_card['type_arg'] + 1] ?? false))
    {
        throw new BgaUserException(self::_(
            "Your card must be a spinner or one higher or lower in value than a card already played"));
    }
}

If the current card is not a valid play an exception is raised giving the player some idea as to what wasn't right.

Allow the player to pass

While I want the system to know if the player needs to pass, I also want to be able to play through a round now. However, looking into this reminded me that the "Play Card" action button needs to be added when the preference for confirmation is enabled. Both of these changes are in the onUpdateActionButtons function in the front end.
case 'dominoesPlayerTurn':
    if (this.prefs[100].value == 1) {
        this.addActionButton('btnPlayCard', _('Play card'), 'onBtnPlayCard');
    }
    if (args.passable) this.addActionButton('btnPass', _('Pass'), 'onBtnPass');
    break;

I updated the argDominoesPlayerTurn function in the backend to pass in the passable argument.
function argDominoesPlayerTurn() {
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    if (count($cards_in_hands) == 32) {
        return [
            'play_message' => clienttranslate('the spinner'),
            'passable' => false,
        ];
    }
    return [
        'play_message' => clienttranslate('a card or pass'),
        'passable' => true,
    ];
}

Then I implemented the onBtnPass function to support the new button.
onBtnPass: function() {
    const action = "pass";
    if (!this.checkAction(action)) return;

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

Since that's an action it requires an update to the <game_name>.action.php file.
public function pass() {
    self::setAjaxMode();
    $this->game->pass();
    self::ajaxResponse();
}

Followed by the pass function in the <game_name>.name.php file.
function pass() {
    self::checkAction("pass");

    self::notifyAllPlayers('pass', clienttranslate('${player_name} passed'), [
        'player_name' => self::getActivePlayerName()
    ]);
    $this->gamestate->nextState("turnTaken");
}

Nothing too fancy, just advancing the active player and the state.

Conclusion

There's still plenty to do before being able to play Dominoes, but I think that's enough for now. Next up I'll be redoing how the cards are shown on the table so that they display in columns in a center area.

Saturday, January 6, 2024

Make Playing a Little Easier

Overview

Finally getting to the point where the game can be played most of the way through. Scoring is available for all but two of the games and which games have been selected are shown for each player. The next step I wanted to take was to make the gameplay a little quicker, so let's get into it.

Player preference to not verify card play

Currently, to play a card the player must select the card, then click the "Play card" button. Some players (and certainly me while testing it) will just want the cards to be played when they are selected. Since this is an individual player preference it should be handled with the user preferences. Which involved creating a new gamepreferences.json file and defining the preference with some details about its behavior.
{
    "100": {
        "name": "Prompt for card play confirmation",
        "needReload": true,
        "values": {
            "1": {
                "name": "Enabled"
            },
            "2": {
                "name": "Disabled"
            }
        },
        "default": 1
    }
}

All of the changes I wanted to make related to this were in the front end and referencing this preference is done through this.prefs[100]. The first thing I did was to not show the action button to the player. This change was done in the onUpdateActionButtons function.
case 'playerTurn':
    if (this.prefs[100].value == 1) {
        this.addActionButton('btnPlayCard', _('Play card'), 'onBtnPlayCard');
    }
    break;

I check if the value is 1, indicating it's enabled and that we want to show the action button.  Then I want to play a card when it is selected. In the setup function, we want to add a listener to the player's hand.
if (this.prefs[100].value == 2) {
    dojo.connect(this.playerHand, 'onChangeSelection', this, 'onHandCardSelect');
}

In this case, we're checking if the value is 2, indicating it's disabled and the action button won't be shown. Here's the onHandCardSelection function.
onHandCardSelect: function(control_name, item_id) {
    console.log("onHandCardSelect listener");
    if (!this.isCurrentPlayerActive()) return;
    if (item_id === undefined) return;

    this.onBtnPlayCard();
},

The check for undefined short circuits this function the second time it is called - when the card is unselected.

Finally, there's the case when someone has preselected the card they want to play and we want to play it as soon as it gets to their turn. This means a change to the onEnteringState function.
onEnteringState: function( stateName, args ) {
    console.log( 'Entering state: '+stateName );
    switch( stateName )
    {
    case 'playerTurn':
        if (this.isCurrentPlayerActive()) {
            if (this.prefs[100].value == 2) {
                const selected_cards = this.playerHand.getSelectedItems();
                if (selected_cards.length === 1) {
                    this.onBtnPlayCard();
                }
            }
        }
        break;
 
    case 'dummmy':
        break;
    }
},

End a hand early when there are no more points

Some games need to be played all the way through, but others can have all of the points in the hand come out in the first round. It's a little tedious to need to play through the rest of the cards when nothing will come of it. Since this will vary based on the game selected it seems like a good place to revisit the scorer classes I created for calculating how many points each player took in their hands. I added a new remainingPoints function that returns true if there are cards in hand that score points for the game. Let's take a look at the implementation for the game Queens.
function remainingPoints(array $cards_in_hands): bool {
    foreach ($cards_in_hands as $card) {
        if ($card['type_arg'] == QUEEN || ($card['type'] == HEART && $card['type_arg'] == KING)) {
            return true;
        }
    }
    return false;
}

This checks the given array of cards to see if there are any Queens or the King of Hearts. This will be used in the stNextPlayer function in the <game_name>.game.php file.
if ($this->cards->countCardInLocation(HAND) == 0) {
    $this->gamestate->nextState("endHand");
} else {
    $scorer = $this->getScorer();
    $cards_in_hands = $this->cards->getCardsInLocation(HAND);
    if (!$scorer->remainingPoints($cards_in_hands)) {
        $cards_left = [];
        $players = $this->loadPlayersBasicInfos();
        foreach ($players as $player_id => $player) {
            $cards_left_list = [];
            $hand = $this->cards->getCardsInLocation(HAND, $player_id);
            usort($hand, [$this, "sortCards"]);
            foreach ($hand as $card) {
                $cards_left_list[] = $this->suits[$card['type']]['name'].''.$this->values_label[$card['type_arg']];
            }
            $cards_left[] = self::getPlayerNameById($player_id).' - '.implode(', ', $cards_left_list);
        }
        $cards_left_final = implode('<br>', $cards_left);
        self::notifyAllPlayers('earlyEnd', clienttranslate('Ending the hand early as all scoring cards are out<br><br>Cards left:<br>${cards_left}'), [
            'cards_left' => $cards_left_final,
            'remaining_cards' => $cards_in_hands,
        ]);
        $this->gamestate->nextState("endHand");
    } else {
        $this->gamestate->nextState("nextTrick");
    }
}

If no points are remaining in anyone's hand, this provides a notification to inform players what cards are still out there. The front end will make use of that notification to update where the cards are visually. As is usual the <game_name>.js file needs to be updated in two places. The first is adding a subscription to the "earlyEnd" notification in the setupNotifications function. The next is the implementation of the function called for that notification - notif_earlyEnd.
notif_earlyEnd: function(notif) {
    for (let i in notif.args.remaining_cards) {
        const card = notif.args.remaining_cards[i];
        this.playCardOnTable(card.location_arg, card.type, card.type_arg, card.id);
    }

    document.querySelectorAll('.cardontable').forEach(e => this.slideToObjectAndDestroy(e, 'playertables'));
},

This puts all of the cards onto the table, then slides them to the "playertables" container, which holds the played cards and removes them from view.

Conclusion

I thought checking for point cards was going to be harder than it ended up being. I started exploring creating a Card class to encapsulate checking for equality and seeing if I could make it read better when they were created, but I ended up giving up on it. It seemed a small improvement for the work, though maybe I'll have another idea around it in the future.

There are still some things that might speed up the gameplay - for instance, recognizing if the player can't lose the rest of the tricks - but I think I want to be able to finish a game. That means finishing up the scoring for Guillotine and finally getting around to dealing with Dominoes. That might need to be split over a couple of sessions as it plays completely differently.