Thursday, January 11, 2024

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.

No comments: