diff --git a/gameinfos.inc.php b/gameinfos.inc.php index e5fbff7..87aa2f7 100644 --- a/gameinfos.inc.php +++ b/gameinfos.inc.php @@ -42,8 +42,8 @@ // Board game geek ID of the game 'bgg_id' => 323262, - // Players configuration that can be played (ex: 2 to 4 players) - 'players' => [3, 4, 5], + // Players configuration that can be played (2 to 5 players) + 'players' => [2, 3, 4, 5], // Suggest players to play with this number of players. Must be null if there is no such advice, or if there is only one possible player configuration. 'suggest_player_number' => null, diff --git a/modules/constants.inc.php b/modules/constants.inc.php index 20cfe1b..2e6e1e6 100644 --- a/modules/constants.inc.php +++ b/modules/constants.inc.php @@ -14,6 +14,7 @@ define('ST_ACTIVATE_NEXT_PLAYER', 22); define('ST_PLAYER_SELECT_NEXT_PLAYER', 30); define('ST_APPLY_SELECTED_NEXT_PLAYER', 31); +define('ST_PLAYER_SELECT_WHO_TAKE_ATTACK_REWARD', 38); define('ST_PLAYER_PICK_CARDS_FROM_PLAYER', 40); define('ST_PLAYER_GIVE_CARDS_BACK_TO_PLAYER_AFTER_PICKING', 41); define('ST_END_ROUND', 80); diff --git a/states.inc.php b/states.inc.php index 22e6c2d..9cfcd97 100644 --- a/states.inc.php +++ b/states.inc.php @@ -109,7 +109,12 @@ 'description' => '', 'type' => 'game', 'action' => 'stActivateNextPlayer', - 'transitions' => ['firstPlayerTurn' => ST_FIRST_PLAYER_TURN, 'playerTurn' => ST_PLAYER_TURN, 'playerSelectNextPlayer' => ST_PLAYER_SELECT_NEXT_PLAYER], + 'transitions' => [ + 'firstPlayerTurn' => ST_FIRST_PLAYER_TURN, + 'playerTurn' => ST_PLAYER_TURN, + 'playerSelectNextPlayer' => ST_PLAYER_SELECT_NEXT_PLAYER, + 'playerSelectWhoTakeAttackReward' => ST_PLAYER_SELECT_WHO_TAKE_ATTACK_REWARD, + ], ], // The "natural" next player don't have cards anymore, @@ -136,6 +141,22 @@ 'transitions' => ['firstPlayerTurn' => ST_FIRST_PLAYER_TURN], ], + // /!\ 2P mode only + // When a player wins an attack, + // he has to choose if it takes the reward or if he gives it to its opponent + ST_PLAYER_SELECT_WHO_TAKE_ATTACK_REWARD => [ + 'name' => 'playerSelectWhoTakeAttackReward', + 'description' => clienttranslate('${actplayer} must choose who will take the attacker reward'), + 'descriptionmyturn' => clienttranslate('${you} must choose who will take the attacker reward'), + 'type' => 'activeplayer', + 'args' => 'argPlayerSelectWhoTakeAttackReward', + 'possibleactions' => ['selectWhoTakeAttackReward'], + 'transitions' => [ + 'firstPlayerTurn' => ST_FIRST_PLAYER_TURN, + 'zombiePass' => ST_ACTIVATE_NEXT_PLAYER, + ], + ], + // When someone plays one or more cards of value "1", // this player has to pick one or more cards from another player of its choice ST_PLAYER_PICK_CARDS_FROM_PLAYER => [ diff --git a/velonimo.action.php b/velonimo.action.php index ad9adc9..f62dec2 100644 --- a/velonimo.action.php +++ b/velonimo.action.php @@ -71,6 +71,20 @@ public function selectNextPlayer() self::ajaxResponse(); } + /** + * /!\ 2P mode only + */ + public function selectWhoTakeAttackReward() + { + self::setAjaxMode(); + + $selectedPlayerId = trim(self::getArg('selectedPlayerId', AT_int, true)); + + $this->game->selectWhoTakeAttackReward((int) $selectedPlayerId); + + self::ajaxResponse(); + } + public function selectPlayerToPickCards() { self::setAjaxMode(); diff --git a/velonimo.css b/velonimo.css index dae2907..1625b75 100644 --- a/velonimo.css +++ b/velonimo.css @@ -135,15 +135,6 @@ Board .player-table.is-wearing-jersey .player-table-hand { margin: 5px 0 0 -6px; } -.player-table-hand .number-of-cards { - position: absolute; - left: 8px; - top: 20px; - font-size: 2em; - text-align: center; - width: 1em; - pointer-events: none; -} .player-table-speech-bubble { position: absolute; top: 0; @@ -235,6 +226,26 @@ Board cards height: 126px; /* cardHeight */ z-index: 8; } +/* /!\ 2P mode only */ +#cards-deck { + position: absolute; + display: inline-block; + top: 177px; /* (containerHeight / 2) - (selfHeight / 2) */ + left: 10px; /* boardMargin */ + width: 90px; /* cardWidth */ + height: 126px; /* cardHeight */ + background-image: url('img/remaining_cards.png'); + background-size: 90px 126px; +} +/* /!\ 2P mode only */ +#attack-reward-card { + position: absolute; + display: inline-block; + top: 177px; /* (containerHeight / 2) - (selfHeight / 2) */ + left: 110px; /* cardWidth + (2 * boardMargin) */ + width: 90px; /* cardWidth */ + height: 126px; /* cardHeight */ +} /** END Board cards */ @@ -313,6 +324,15 @@ Cards .cards-stack .velonimo-card { margin-right: -60px; /* 2/3 of card width */ } +.text-on-cards { + position: absolute; + left: 8px; + top: 20px; + font-size: 2em; + text-align: center; + width: 1em; + pointer-events: none; +} /** END Cards */ diff --git a/velonimo.game.php b/velonimo.game.php index 892cb5a..4615cc6 100644 --- a/velonimo.game.php +++ b/velonimo.game.php @@ -36,9 +36,8 @@ class Velonimo extends Table private const GAME_STATE_LAST_NUMBER_OF_CARDS_TO_PICK = 'numberOfCardsToPick'; private const GAME_STATE_LAST_NUMBER_OF_CARDS_TO_GIVE_BACK = 'numberOfCardsToGiveBack'; private const GAME_STATE_LAST_PLAYER_ID_TO_GIVE_CARDS_BACK = 'playerIdToGiveCardsBack'; - /* START 2P */ + // /!\ 2P mode only private const GAME_STATE_LAST_NUMBER_OF_CARDS_TO_PICK_FROM_DECK = 'numberOfCardsToPickFromDeck'; - /* END 2P */ private const GAME_OPTION_HOW_MANY_ROUNDS = 'howManyRounds'; @@ -47,9 +46,8 @@ class Velonimo extends Table private const CARD_LOCATION_DISCARD = 'discard'; private const CARD_LOCATION_PLAYED = 'played'; private const CARD_LOCATION_PREVIOUS_PLAYED = 'previousPlayed'; - /* START 2P */ - private const CARD_LOCATION_ATTACK_WINNER_REWARD = 'attackReward'; - /* END 2P */ + // /!\ 2P mode only + private const CARD_LOCATION_ATTACK_REWARD = 'attackReward'; /** @var Deck (BGA framework component to manage cards) */ private $deck; @@ -229,10 +227,11 @@ protected function getAllDatas() { $this->fromBgaCardsToVelonimoCards($this->deck->getCardsInLocation(self::CARD_LOCATION_PREVIOUS_PLAYED)) ); $result['previousPlayedCardsValue'] = (int) self::getGameStateValue(self::GAME_STATE_PREVIOUS_PLAYED_CARDS_VALUE); + if ($this->is2PlayersMode($players)) { - $result['numberOfCardsInDeck'] = count($this->deck->getCardsInLocation(self::CARD_LOCATION_DECK)); + $result['numberOfCardsInDeck'] = $this->getNumberOfCardsInDeck(); $result['attackRewardCards'] = $this->formatCardsForClient( - $this->fromBgaCardsToVelonimoCards($this->deck->getCardsInLocation(self::CARD_LOCATION_ATTACK_WINNER_REWARD)) + $this->fromBgaCardsToVelonimoCards($this->deck->getCardsInLocation(self::CARD_LOCATION_ATTACK_REWARD)) ); } @@ -369,8 +368,44 @@ function playCards(array $playedCardIds, bool $cardsPlayedWithJersey) { $this->setStat($playedCardsValue, 'maxValue', $currentPlayerId); } + $numberOfCurrentPlayerCards = count($currentPlayerCards); + + if ($this->is2PlayersMode($players)) { + // if the player played cards of value "2", he has to pick as many cards from deck + $numberOfPlayedCardsOfValueTwo = count( + array_filter($playedCards, fn (VelonimoCard $c) => $c->getValue() === VALUE_2) + ); + if ($numberOfPlayedCardsOfValueTwo > 0) { + $numberOfCardsInDeck = $this->getNumberOfCardsInDeck(); + $numberOfCardsToPickFromDeck = min($numberOfPlayedCardsOfValueTwo, $numberOfCardsInDeck); + + if ($numberOfCardsToPickFromDeck > 0) { + $cardsPickedFromDeck = $this->fromBgaCardsToVelonimoCards( + $this->deck->pickCards($numberOfPlayedCardsOfValueTwo, self::CARD_LOCATION_DECK, $currentPlayer->getId()) + ); + $numberOfCurrentPlayerCards = $numberOfCurrentPlayerCards + $numberOfCardsToPickFromDeck; + $translatedMessage = clienttranslate('${player_name} picks ${numberOfCards} card(s) from the top of the deck'); + foreach ($players as $player) { + if ($player->getId() === $currentPlayer->getId()) { + self::notifyPlayer($currentPlayer->getId(), 'cardsReceivedFromDeck', $translatedMessage, [ + 'cards' => $this->formatCardsForClient($cardsPickedFromDeck), + 'numberOfCards' => $numberOfCardsToPickFromDeck, + 'player_name' => $currentPlayer->getName(), + ]); + } else { + self::notifyPlayer($player->getId(), 'cardsMovedFromDeckToAnotherPlayer', $translatedMessage, [ + 'receiverPlayerId' => $currentPlayer->getId(), + 'numberOfCards' => $numberOfCardsToPickFromDeck, + 'player_name' => $currentPlayer->getName(), + ]); + } + } + } + } + } + // if the player played his last card, set its rank for this round - if ((count($currentPlayerCards) - $numberOfPlayedCards) === 0) { + if (($numberOfCurrentPlayerCards - $numberOfPlayedCards) === 0) { $currentRound = (int) self::getGameStateValue(self::GAME_STATE_CURRENT_ROUND); $nextRankForRound = $this->getNextRankForRound($players, $currentRound); $currentPlayer->addRoundRanking($currentRound, $nextRankForRound); @@ -398,35 +433,6 @@ function playCards(array $playedCardIds, bool $cardsPlayedWithJersey) { return; } - if ($this->is2PlayersMode($players)) { - // if the player played cards of value "2", he has to pick as many cards from deck - $numberOfPlayedCardsOfValueTwo = count( - array_filter($playedCards, fn (VelonimoCard $c) => $c->getValue() === VALUE_2) - ); - if ($numberOfPlayedCardsOfValueTwo > 0) { - $cardsPickedFromDeck = $this->fromBgaCardsToVelonimoCards( - $this->deck->pickCards($numberOfPlayedCardsOfValueTwo, self::CARD_LOCATION_DECK, $currentPlayer->getId()) - ); - $numberOfCardsToPickFromDeck = count($cardsPickedFromDeck); - $translatedMessage = clienttranslate('${player_name} picks ${numberOfCards} card(s) from the top of the deck'); - foreach ($players as $player) { - if ($player->getId() === $currentPlayer->getId()) { - self::notifyPlayer($currentPlayer->getId(), 'cardsReceivedFromDeck', $translatedMessage, [ - 'cards' => $this->formatCardsForClient($cardsPickedFromDeck), - 'numberOfCards' => $numberOfCardsToPickFromDeck, - 'player_name' => $currentPlayer->getName(), - ]); - } else { - self::notifyPlayer($player->getId(), 'cardsMovedFromDeckToAnotherPlayer', $translatedMessage, [ - 'receiverPlayerId' => $currentPlayer->getId(), - 'numberOfCards' => $numberOfCardsToPickFromDeck, - 'player_name' => $currentPlayer->getName(), - ]); - } - } - } - } - // if the player played cards of value "1", he has to pick as many cards from another player $numberOfPlayedCardsOfValueOne = count( array_filter($playedCards, fn (VelonimoCard $c) => $c->getValue() === VALUE_1) @@ -483,6 +489,62 @@ function selectNextPlayer(int $selectedPlayerId) { $this->gamestate->nextState('applySelectedNextPlayer'); } + /** + * /!\ 2P mode only + */ + function selectWhoTakeAttackReward(int $selectedPlayerId) { + self::checkAction('selectWhoTakeAttackReward'); + + $players = $this->getPlayersFromDatabase(); + if (!$this->is2PlayersMode($players)) { + $this->gamestate->nextState('firstPlayerTurn'); + + return; + } + + try { + $selectedPlayer = $this->getPlayerById($selectedPlayerId, $players); + $currentPlayer = $this->getPlayerById((int) self::getCurrentPlayerId(), $players); + } catch (\Throwable $e) { + throw new BgaUserException(self::_('This player does not exist.')); + } + + $rewardCards = $this->fromBgaCardsToVelonimoCards( + $this->deck->getCardsInLocation(self::CARD_LOCATION_ATTACK_REWARD) + ); + // skip if no more reward + if (count($rewardCards) <= 0) { + $this->gamestate->nextState('firstPlayerTurn'); + + return; + } + + // give reward to selected player + $this->deck->moveCards( + array_map(fn (VelonimoCard $c) => $c->getId(), $rewardCards), + self::CARD_LOCATION_PLAYER_HAND, + $selectedPlayer->getId() + ); + + // notify players + if ($currentPlayer->getId() === $selectedPlayer->getId()) { + $translatedMessage = clienttranslate('${player_name} takes the attacker reward'); + } else { + $translatedMessage = clienttranslate('${player_name} gives the attacker reward to ${player_name2}'); + } + self::notifyAllPlayers('attackRewardCardsMovedToPlayer', $translatedMessage, [ + 'cards' => $this->formatCardsForClient($rewardCards), + 'receiverPlayerId' => $selectedPlayer->getId(), + 'player_name' => $currentPlayer->getName(), + 'player_name2' => $selectedPlayer->getName(), + ]); + + // reveal new attack reward + $this->revealNewAttackRewardCardsIfEnoughCardsInDeck(); + + $this->gamestate->nextState('firstPlayerTurn'); + } + function selectPlayerToPickCards(int $selectedPlayerId) { self::checkAction('selectPlayerToPickCards'); @@ -682,6 +744,20 @@ function argPlayerSelectNextPlayer() { ]; } + /** + * /!\ 2P mode only + */ + function argPlayerSelectWhoTakeAttackReward() { + $activePlayerId = (int) self::getActivePlayerId(); + + return [ + 'activePlayerId' => $activePlayerId, + 'selectablePlayers' => $this->formatPlayersForClient( + $this->getPlayersFromDatabase(), + ), + ]; + } + function argPlayerSelectPlayerToPickCards() { $currentRound = (int) self::getGameStateValue(self::GAME_STATE_CURRENT_ROUND); $activePlayerId = (int) self::getActivePlayerId(); @@ -749,13 +825,7 @@ function stStartRound() { ]); if ($this->is2PlayersMode($players)) { - $this->deck->pickCardForLocation(self::CARD_LOCATION_DECK, self::CARD_LOCATION_ATTACK_WINNER_REWARD); - - self::notifyAllPlayers('attackRewardCardsRevealed', 'A new attack reward card is revealed', [ - 'attackRewardCards' => $this->formatCardsForClient( - $this->fromBgaCardsToVelonimoCards($this->deck->getCardsInLocation(self::CARD_LOCATION_ATTACK_WINNER_REWARD)) - ), - ]); + $this->revealNewAttackRewardCardsIfEnoughCardsInDeck(); } $this->gamestate->nextState('firstPlayerTurn'); @@ -774,15 +844,25 @@ function stActivateNextPlayer() { foreach ($players as $ignored) { $nextPlayerId = self::activeNextPlayer(); $nextPlayerCanPlay = in_array($nextPlayerId, $playersWhoCanPlayIds, true); - // if the next player is the one who played the last played cards, remove the cards from the table + // if the next player is the one who played the last played cards if ($nextPlayerId === $playerIdWhoPlayedTheLastCards) { $this->discardPlayedCards(); self::giveExtraTime($nextPlayerId); + + if ( + $this->is2PlayersMode($players) + && $this->getNumberOfCardsInAttackReward() > 0 + ) { + $this->gamestate->nextState('playerSelectWhoTakeAttackReward'); + return; + } + if ($nextPlayerCanPlay) { $this->gamestate->nextState('firstPlayerTurn'); - } else { - $this->gamestate->nextState('playerSelectNextPlayer'); + return; } + + $this->gamestate->nextState('playerSelectNextPlayer'); return; } if ($nextPlayerCanPlay) { @@ -1250,4 +1330,37 @@ private function updatePlayerRoundsRanking(VelonimoPlayer $player): void { private function isJerseyUsedInCurrentRound(): bool { return 1 === (int) self::getGameStateValue(self::GAME_STATE_JERSEY_HAS_BEEN_USED_IN_THE_CURRENT_ROUND); } + + /** + * /!\ 2P mode only + */ + private function getNumberOfCardsInDeck(): int { + return count($this->deck->getCardsInLocation(self::CARD_LOCATION_DECK)); + } + + /** + * /!\ 2P mode only + */ + private function getNumberOfCardsInAttackReward(): int { + return count($this->deck->getCardsInLocation(self::CARD_LOCATION_ATTACK_REWARD)); + } + + /** + * /!\ 2P mode only + */ + private function revealNewAttackRewardCardsIfEnoughCardsInDeck(): void { + if ($this->getNumberOfCardsInDeck() <= 0) { + return; + } + + $this->deck->pickCardForLocation(self::CARD_LOCATION_DECK, self::CARD_LOCATION_ATTACK_REWARD); + + self::notifyAllPlayers('attackRewardCardsRevealed', 'A new attack reward card is revealed', [ + 'cards' => $this->formatCardsForClient( + $this->fromBgaCardsToVelonimoCards( + $this->deck->getCardsInLocation(self::CARD_LOCATION_ATTACK_REWARD) + ) + ), + ]); + } } diff --git a/velonimo.js b/velonimo.js index 0b9b51c..152200f 100644 --- a/velonimo.js +++ b/velonimo.js @@ -65,6 +65,8 @@ const CARD_ID_JERSEY = 0; // DOM IDs const DOM_ID_APP = 'velonimo-game'; const DOM_ID_BOARD_CARPET = 'board-carpet'; +const DOM_ID_CARDS_DECK = 'cards-deck'; +const DOM_ID_ATTACK_REWARD_CARD = 'attack-reward-card'; const DOM_ID_PLAYED_CARDS_WRAPPER = 'played-cards'; const DOM_ID_LAST_PLAYED_CARDS = 'last-played-cards'; const DOM_ID_PREVIOUS_LAST_PLAYED_CARDS = 'previous-last-played-cards'; @@ -83,6 +85,7 @@ const DOM_CLASS_PLAYER_TABLE = 'player-table'; const DOM_CLASS_PLAYER_IS_WEARING_JERSEY = 'is-wearing-jersey'; const DOM_CLASS_PLAYER_HAS_USED_JERSEY = 'has-used-jersey'; const DOM_CLASS_CARDS_STACK = 'cards-stack'; +const DOM_CLASS_TEXT_ON_CARDS = 'text-on-cards'; const DOM_CLASS_CARDS_STACK_PREVIOUS_PLAYED = 'previous-last-played-cards'; const DOM_CLASS_DISABLED_ACTION_BUTTON = 'disabled'; const DOM_CLASS_ACTIVE_PLAYER = 'active'; @@ -248,7 +251,7 @@ function (dojo, declare) { dojo.place( `