This is a documentation for Board Game Arena: play board games online !
Tutorial hearts: Difference between revisions
No edit summary |
No edit summary |
||
| (84 intermediate revisions by 9 users not shown) | |||
| Line 7: | Line 7: | ||
Before you read this tutorial, you must: | Before you read this tutorial, you must: | ||
* Read the overall presentations of the BGA Framework ([[Studio|see here]]). | * Read the overall presentations of the BGA Framework ([[Studio|see here]]). | ||
* Some-what know the languages used on BGA: PHP, SQL, HTML, CSS, Javascript | * Some-what know the languages used on BGA: PHP, SQL, HTML, CSS, Javascript | ||
* Set up your development environment [http://en.doc.boardgamearena.com/First_steps_with_BGA_Studio First Steps with BGA Studio] | * Set up your development environment [http://en.doc.boardgamearena.com/First_steps_with_BGA_Studio First Steps with BGA Studio] | ||
| Line 13: | Line 12: | ||
If you are stuck or have question about this tutorial, post on [https://forum.boardgamearena.com/viewforum.php?f=12 BGA Developers forum] | If you are stuck or have question about this tutorial, post on [https://forum.boardgamearena.com/viewforum.php?f=12 BGA Developers forum] | ||
Note: If you get stuck and you need the source code for this project you can get read only access to project hearts from the Studio projects page : https://studio.boardgamearena.com/projects?game=hearts, only check "Already published" to see the real project. <i>There might be some slight differences as we simplified the tutorial compared to the full production project.</i> | |||
=== Javascript or Typescript === | |||
If you're not familiar with Typescript, we recommend following the tutorial using the Javascript version. | |||
If you're more comfortable with Typescript than plain Javascript, you can try to follow the Typescript version of the tutorial. | |||
Any difference between the Javascript and the Typescript version of this tutorial will be <span style="outline: 3px solid #3178C6; padding: 5px;margin: 5px;"><span style="background: #3178C6; color: white; margin-right: 5px;">TS</span>outlined with this color</span>. | |||
== Hearts Rules == | |||
Hearts is a trick-taking card game for four players where the goal is to score the fewest points. | |||
Players aim to avoid taking tricks with heart cards (1 point each) and the Queen of Spades (13 points). | |||
Each round, 13 cards are dealt, players pass three cards, and the player with the 2 of Clubs starts the first trick. | |||
Play continues clockwise, with players needing to follow suit if they can, and the highest card of the lead suit wins the trick. | |||
Hearts cannot be played until they are "broken" by a player who can't follow suit and discards a heart, or by a player leading with a heart after they've been broken. | |||
== Create your first game == | == Create your first game == | ||
If you have not already, you have to create a project in BGA Studio. For this tutorial you can create a project heartsYOURNAME where | If you have not already, you have to create a project in BGA Studio. For this tutorial you can create a project heartsYOURNAME where | ||
YOURNAME is your developer login name. You can also re-use the project you have created for the "First Steps" tutorial above. | YOURNAME is your developer login name (or shorter version of thereof). You can also re-use the project you have created for the "First Steps" tutorial above. | ||
Go to [https://studio.boardgamearena.com/studio#new-tutorial Manage game page] to create your tutorial project. | |||
<i> | |||
Note: please do '''not''' use the hearts project code as a base. This tutorial assumes you started with a TEMPLATE project with no prior modifications. | |||
Using the hearts project as a base will be very confusing and you won't be able to follow all the steps. Also it will not match exactly with this tutorial for different reasons. | |||
</i> | |||
With the initial skeleton of code provided, you can already start a game from the BGA Studio. | With the initial skeleton of code provided, you can already start a game from the BGA Studio. | ||
1. Find and express start the game in turn-based mode with 4 players. Make sure it works. If you want to see the game as 2nd player press red arrow button on the player panel to switch to that player. More details can be found in [[First_steps_with_BGA_Studio]] | 1. Find and express start the game in turn-based mode with 4 players. Make sure it works. If you want to see the game as 2nd player press red arrow button on the player panel to switch to that player. More details can be found in [[First_steps_with_BGA_Studio]] | ||
2. Modify the text in | 2. Modify the text in .js file (for example replace "Player zone content goes here" to "Hello"), reload the page in the browser and make sure your ftp sync works as expected. | ||
Note: if you have not setup auto-sync do it now, manually copying files is a no-starter. | Note: if you have not setup auto-sync do it now, manually copying files is a no-starter. | ||
3. Express stop from settings menu (the gear icon). | 3. Express stop from settings menu (the gear icon). | ||
<b>Attention!!!</b> Very important note about reloading, if you don't remember this you may spend hours debugging. The browser caches images. If you change any of these files, you have to do "full reload" which is usually Ctrl+F5 (or Ctrl+reload button on browser) not just a regular reload. | <b>Attention!!!</b> Very important note about reloading, if you don't remember this you may spend hours debugging. The browser caches images. If you change any of these files, you have to do "full reload" which is usually Ctrl+F5 (or Ctrl+reload button on browser) not just a regular reload. | ||
'''If you don't want to use TypeScript in your project,''' you can delete the package.json, tsconfig.json, rollup.config.mjs files and the src-disabled folder. | |||
== Hook version control system == | == Hook version control system == | ||
| Line 40: | Line 65: | ||
Different revisions represent different steps along the process, starting from original template to a PARTIAL game. | Different revisions represent different steps along the process, starting from original template to a PARTIAL game. | ||
The | Note: the game was re-written using new template, the old code is in "oldframework" branch. The new template is in main branch. | ||
The real hearts game (that you can play on BGA) can be found in your FTP home folder, after getting read-only access, go to https://studio.boardgamearena.com/projects, select Already Published and find Hearts to get access | |||
(It may not match this tutorial as framework diverged since this game was created and it may not have been updated) | |||
== Update game infos and box graphics == | == Update game infos and box graphics == | ||
Even it does nothing yet, always start by making sure the game looks decent in the game selector, meaning it has nice box graphics and its information is correct. For that we need to edit [[Game_meta-information: gameinfos. | Even it does nothing yet, always start by making sure the game looks decent in the game selector, meaning it has nice box graphics and its information is correct. For that we need to edit [[Game_meta-information: gameinfos.jsonc|gameinfos.jsonc]]. | ||
For a real game, you would go to [http://boardgamegeek.com BoardGameGeek], find the game, and use the information from BGG to fill in the gameinfos. | For a real game, you would go to [http://boardgamegeek.com BoardGameGeek], find the game, and use the information from BGG to fill in the gameinfos. | ||
| Line 51: | Line 79: | ||
You can fill in the year of publishing and bgg id, put ''Public Domain'' under publisher (for a real game, leave an empty string so it won't be displayed), and a publisher id of 171 for public domain. And as designer and author you can just put your own name just for fun. Set number of players to 4. | You can fill in the year of publishing and bgg id, put ''Public Domain'' under publisher (for a real game, leave an empty string so it won't be displayed), and a publisher id of 171 for public domain. And as designer and author you can just put your own name just for fun. Set number of players to 4. | ||
// Game publisher | |||
'publisher' => 'Public Domain', | |||
// Board Game Geek ID of the publisher | |||
'publisher_bgg_id' => 171, | |||
// Players configuration that can be played (ex: 2 to 4 players) | // Players configuration that can be played (ex: 2 to 4 players) | ||
| Line 60: | Line 94: | ||
The next step would be to replace | The next step would be to replace game box with nicer images. This can be done from the [[Game metadata manager]]. | ||
Now try to start the game again. If you somehow introduced a syntax error in the gameinfos file it may not work (the game won't start). | Now try to start the game again. If you somehow introduced a syntax error in the gameinfos file it may not work (the game won't start). | ||
| Line 68: | Line 102: | ||
<i>Note: if you had run the game before with less than 4 players there is a bug that will prevent you from running it with 4 only (if you did not run it before or run it with 4 players as instructed stop reading this note), to workaround revert back to original players array (i.e. 1,2,3,4), reload game options, then create a table with 4 players, exit that game table, then change gameoptions to 4 only as above, reload game options, create table again.</i> | <i>Note: if you had run the game before with less than 4 players there is a bug that will prevent you from running it with 4 only (if you did not run it before or run it with 4 players as instructed stop reading this note), to workaround revert back to original players array (i.e. 1,2,3,4), reload game options, then create a table with 4 players, exit that game table, then change gameoptions to 4 only as above, reload game options, create table again.</i> | ||
== Layout and Graphics == | == Layout and Graphics == | ||
| Line 75: | Line 107: | ||
In this section we will do graphics of the game, and main layout of the game. | In this section we will do graphics of the game, and main layout of the game. | ||
First copy a sprite with cards image from [https:// | First copy a sprite with cards image from [https://x.boardgamearena.net/data/others/cards/FULLREZ_CARDS_ORIGINAL_NORMAL.jpg] into img/cards.jpg folder of your project. | ||
Details about images can be found here: [[Game art: img directory]]. | Details about images can be found here: [[Game art: img directory]]. If you did not setup auto-sync of files, sync the graphics manually with remote folder (re-sync with your workspace). | ||
Edit .js to add some divs to represent player table and hand area, at the beginning of the setup function | Edit .js to add some divs to represent player table and hand area, at the beginning of the setup function | ||
<pre> | <pre> | ||
setup(gamedatas) { | |||
console.log("Starting game setup"); | |||
this.bga.gameArea.getElement().insertAdjacentHTML( | |||
"beforeend", | |||
` | |||
<div id="myhand_wrap" class="whiteblock"> | <div id="myhand_wrap" class="whiteblock"> | ||
<b id="myhand_label">${_( | <b id="myhand_label">${_("My hand")}</b> | ||
<div id="myhand"> | |||
</div> | |||
</div> | |||
`); | `, | ||
); | |||
// ... | |||
</pre> | </pre> | ||
<div style="outline: 3px solid #3178C6; padding: 5px;margin: 5px;"><span style="background: #3178C6; color: white; margin-right: 5px;">TS</span>Whenever the tutorial mentions Game.js, change the src/ts/Game.ts instead. Make sure you have installed the necessary dependencies with <code>npm i</code> then trigger autobuild with <code>npm run build:ts</code> so the TS you change is built to modules/js/Game.js. | |||
Do not delete the type signature from setup method of the Game.ts file; leave it as it is. Just add the <code>this.bga.gameArea.getElement().insertAdjacentHTML()</code> block as above.</div>. | |||
If you refresh you should see now white area with My Hand title. | If you refresh you should see now white area with My Hand title. | ||
| Line 97: | Line 142: | ||
[[File:Heartsla-tpl2.png]] | [[File:Heartsla-tpl2.png]] | ||
Now lets add a card into the hand, just so you can feel it. Edit | Now lets add a card into the hand, just so you can feel it. Edit the html snippet we inserted earlier buy adding a line representing a card | ||
<pre> | <pre> | ||
... | ... | ||
<div id="myhand"> | <div id="myhand"> | ||
<div class=" | <div class="fakecard"></div> | ||
</div> | </div> | ||
... | ... | ||
</pre> | </pre> | ||
Edit .css file | Edit .css file, add this code (.css file is empty now, only has comments, just tuck this at the end) | ||
<pre> | <pre> | ||
. | .fakecard { | ||
display: inline-block; | display: inline-block; | ||
position: relative; | position: relative; | ||
margin-top: 5px; | margin-top: 5px; | ||
width: | border-radius: 5%; | ||
height: | width: 100px; | ||
height: 135px; | |||
background-size: calc(100px * 15); | |||
background-image: url('img/cards.jpg'); /* temp hack to see it */ | background-image: url('img/cards.jpg'); /* temp hack to see it */ | ||
} | } | ||
| Line 120: | Line 167: | ||
When you change existing graphics files remember that you have to FORCE-reload page, i.e. Ctrl-F5, otherwise its cached. | When you change existing graphics files remember that you have to FORCE-reload page, i.e. Ctrl-F5, otherwise its cached. | ||
You should see this: | You should see this (more less): | ||
[[File:Heartsla-tpl3.png]] | [[File:Heartsla-tpl3.png]] | ||
Note: If you don't see the card a) check it was synced to remote folder b) force reload page | |||
Awesome! Now lets do the rest of layout. | Awesome! Now lets do the rest of layout. | ||
Let's complete the game template. You template should have this code, just leave it there | |||
<pre> | |||
// Example to add a div on the game area | |||
this.bga.gameArea.getElement().insertAdjacentHTML("beforeend", | |||
<div id="player-tables"></div> | |||
` | |||
); | |||
</pre> | |||
Then change the code following comment "// Setting up player boards" with this | |||
<pre> | <pre> | ||
// Setting up player boards | |||
const numPlayers = Object.keys(gamedatas.players).length; | |||
Object.values(gamedatas.players).forEach((player, index) => { | |||
<div class="playertable whiteblock playertable_${DIRECTIONS | document.getElementById("player-tables").insertAdjacentHTML( | ||
<div class="playertablename" style="color:#${player.color};" | "beforeend", | ||
<div | // we generate this html snippet for each player | ||
` | |||
<div class="playertable whiteblock playertable_${DIRECTIONS[index]}"> | |||
<div class="playertablename" style="color:#${player.color};">${player.name}</div> | |||
<div id="tableau_${player.id}"></div> | |||
</div> | </div> | ||
`) | ` | ||
} | ); | ||
}); | |||
</pre> | </pre> | ||
What we did is we added a template for every players at the table. | |||
Now try to reload you game. | |||
Oops! it won't load. This is to teach you how it will look like when you have syntax error in your js file. The game will hang loading at 10% or so. How to know what happened? | |||
Open dev tools in browser (usually F12) and navigate to Console tab. You will see a stack trace of where error is. In our case | |||
HeartsFIXME.js:68 Uncaught (in promise) ReferenceError: DIRECTIONS is not defined | |||
In real hearts game they use this direction array to map every player to direction (like North) but its not needed, we can just use player index. | |||
Lets just replace DIRECTIONS[index] with index, i.e | |||
<div class="playertable whiteblock playertable_${index}"> | |||
Now delete the following section as we won't be using it<pre> | |||
// Add test action buttons in the action status bar, simulating a card click: | |||
playableCardsIds.forEach((cardId) => | |||
this.bga.statusBar.addActionButton( | |||
_("Play card with id ${card_id}").replace("${card_id}", cardId), | |||
() => this.onCardClick(cardId), | |||
), | |||
); | |||
this.bga.statusBar.addActionButton( | |||
_("Pass"), | |||
() => this.bga.actions.performAction("actPass"), | |||
{ color: "secondary" }, | |||
); | |||
</pre> | </pre> | ||
<div style="outline: 3px solid #3178C6; padding: 5px;margin: 5px;"><span style="background: #3178C6; color: white; margin-right: 5px;">TS</span>The code to be deleted is in src/ts/States/PlayerTurn.ts.</div>. | |||
Reload. If everything went well you should see this: | Reload. If everything went well you should see this: | ||
[[File: | [[File:Heartsla-tpl4.png|alt=Display player space of all players]] | ||
These are "tableau" areas for 4 players plus My hand visible only to one player. They are not exactly how we wanted them to be because we did not edit .css yet. | These are "tableau" areas for 4 players plus My hand visible only to one player. They are not exactly how we wanted them to be because we did not edit .css yet. | ||
| Line 161: | Line 243: | ||
<pre> | <pre> | ||
:root { | |||
--h-card-width: 100px; | |||
--h-card-height: 135px; | |||
--h-tableau-width: 220px; | |||
--h-tableau-height: 180px; | |||
} | |||
# | #player-tables { | ||
position: relative; | |||
width: calc(var(--h-tableau-width) * 3.9); | |||
height: calc(var(--h-tableau-height) * 2.4); | |||
} | } | ||
.playertablename { | .playertablename { | ||
font-weight: bold; | |||
} | } | ||
.playertable { | .playertable { | ||
position: absolute; | |||
text-align: center; | |||
width: var(--h-tableau-width); | |||
height: var(--h-tableau-height); | |||
} | } | ||
. | .playertable_0 { | ||
top: 0px; | |||
left: 50%; | |||
margin-left: calc(var(--h-tableau-width) / 2 * -1); | |||
} | } | ||
. | |||
.playertable_1 { | |||
left: 0px; | |||
top: 50%; | |||
margin-top: calc(var(--h-tableau-height) / 2 * -1); | |||
} | } | ||
. | .playertable_2 { | ||
right: 0px; | |||
top: 50%; | |||
margin-top: calc(var(--h-tableau-height) / 2 * -1); | |||
} | } | ||
. | .playertable_3 { | ||
bottom: 0px; | |||
left: 50%; | |||
margin-left: calc(var(--h-tableau-width) / 2 * -1); | |||
} | } | ||
</pre> | |||
and delete the following section from game.js as we won't be using it<pre> | |||
// example of adding a div for each player | |||
document.getElementById("player-tables").insertAdjacentHTML( | |||
"beforeend", | |||
` | |||
<div id="player-table-${player.id}"> | |||
<strong>${player.name}</strong> | |||
<div>Player zone content goes here</div> | |||
</div> | |||
`, | |||
); | |||
</pre> | </pre> | ||
Now you force Reload and you should see this: | Now you force Reload and you should see this: | ||
[[File:Heartsla-tpl5.png]] | [[File:Heartsla-tpl5.png]] | ||
<i>Note: if you did not see changes you may have not force reloaded, force means you use Ctrl+F5 or Ctrl+Shift-R, if you don't "force" browser will use cached version of images! Which is not what you just changed</i> | |||
Here is some explanations about CSS (if you know everything about css already skip this): | |||
* At top we defined some variables for sizes of cards and player "mats" (which we call tableau) | |||
* We trying to layout mats in kind of diamond shape | |||
* We define positions of our elements using top/bottom/left/right style property | |||
* We used standard technique of centering the element which is use 50% for lets say "left", and then shift by half of size of object to actually center it (margin-left). You can remove margins to see how it look if we did not do that | |||
<i>Another Note: In general if you have auto-sync you don't need to reload if you change Game.php file, you need normal reload if you change js, and force reload for images. If you changed state machine or database you likely need to restart the game.</i> | <i>Another Note: In general if you have auto-sync you don't need to reload if you change Game.php file, you need normal reload if you change js, and force reload for images. If you changed state machine or database you likely need to restart the game.</i> | ||
== Game Interface | == Game Interface with BGA Cards == | ||
The BGA framework provides a few out of the box classes to deal with cards. The client side | The BGA framework provides a few out of the box classes to deal with cards. The client side | ||
contains a | contains a component called [[BgaCards]] and it can be used for any dynamic html "pieces" management and animation. | ||
On the server side we will use [[ItemManager]], with a small CardManager specific to our game, which we discuss later. | |||
If you open cards.jpg in an image viewer you will see that it is a "sprite" image - a 15x4 grid of images stitched together, | |||
which is a very efficient way to transport images. So we will use the card manager class to mark up these images and create | |||
"card" divs for us and place them on the board. | |||
First, we need to add dependencies in the Game.js file: | |||
<pre> | <pre> | ||
const BgaAnimations = await importEsmLib('bga-animations', '1.x'); | |||
const BgaCards = await importEsmLib('bga-cards', '1.x'); | |||
</pre> | </pre> | ||
<div style="outline: 3px solid #3178C6; padding: 5px;margin: 5px;"><span style="background: #3178C6; color: white; margin-right: 5px;">TS</span>Uncomment the code in libs.ts then add <code>import { BgaAnimations, BgaCards } from "./libs";</code> at the very beginning of the Game.ts file</div> | |||
< | |||
Now we will remove the fake card we added (in Game.js file) search and remove: <pre> | |||
<div id="myhand"> | |||
<div class="fakecard"></div> | |||
</div> | |||
</pre> | </pre> | ||
Then we will add initialization code of bga cards and related component in setup method after the template code (i.e. where we defined the myhand div as this div is referenced by the following code) and before setupNotifications <pre> | |||
// create the animation manager, and bind it to the `game.bgaAnimationsActive()` function | |||
this.animationManager = new BgaAnimations.Manager({ | |||
animationsActive: () => this.bga.gameui.bgaAnimationsActive(), | |||
}); | |||
const cardWidth = 100; | |||
const cardHeight = 135; | |||
// create the card manager | |||
this.cardsManager = new BgaCards.Manager({ | |||
animationManager: this.animationManager, | |||
type: "ha-card", // the "type" of our cards in css | |||
getId: (card) => card.id, | |||
cardWidth: cardWidth, | |||
cardHeight: cardHeight, | |||
cardBorderRadius: "5%", | |||
setupFrontDiv: (card, div) => { | |||
div.dataset.suit = card.suit; // suit 1..4 | |||
div.dataset.value = card.value; // value 2..14 | |||
div.style.backgroundPositionX = `calc(100% / 14 * (${card.value} - 2))`; // 14 is number of columns in stock image minus 1 | |||
div.style.backgroundPositionY = `calc(100% / 3 * (${card.suit} - 1))`; // 3 is number of rows in stock image minus 1 | |||
this.bga.gameui.addTooltipHtml(div.id, `tooltip of ${card.suit}`); | |||
}, | |||
}); | |||
// create the stock, in the game setup | |||
this.handStock = new BgaCards.HandStock( | |||
this.cardsManager, | |||
document.getElementById("myhand") | |||
); | |||
// TODO: fix handStock | |||
this.handStock.addCards([ | |||
{ id: 1, suit: 2, value: 4 }, // 4 of hearts | |||
{ id: 2, suit: 3, value: 11 }, // Jack of clubs | |||
]); | |||
</pre> | </pre> | ||
Also we need to add this .css (anywhere), that will map front face of the card to our image (1500% is because this image 15 times bigger than single card on X axis) | |||
<pre> | <pre> | ||
.ha-card-front { | |||
background-size: 1500% auto; | |||
background-image: url("img/cards.jpg"); | |||
} | |||
</pre> | </pre> | ||
Explanations: | Explanations: | ||
* | * First we created animation manager which will be used later | ||
* Then | * Then we define constant with width and height of our cards in pixes | ||
* Then we create the cards manager. We tell it how to get unique id of each card (getId), and how to setup the div representing the front of the card (setupFrontDiv). In this function we set data attributes for suit and value which we will use later, and we set background position to show correct part of sprite image. | |||
* Then we create a hand stock component which will represent player's hand. It is attached to div with id "myhand". | |||
* Finally we add two cards into the hand stock just for testing. | |||
Now | Now if you reload you should see two cards in your hand: | ||
[[File:Heartsla-tpl6.png|alt=Display two cards in player's hand]] | |||
Now we will add the "stock" object that will control player tableau (add in setup function before setupNotification) | |||
<code> | |||
// map stocks | |||
this.tableauStocks = []; | |||
Object.values(gamedatas.players).forEach((player, index) => { | |||
// add player tableau stock | |||
this.tableauStocks[player.id] = new BgaCards.LineStock( | |||
this.cardsManager, | |||
document.getElementById(`tableau_${player.id}`) | |||
); | |||
// TODO: fix tableauStocks | |||
this.tableauStocks[player.id].addCards([ | |||
{ id: index + 10, suit: index + 1, value: index + 2 }, | |||
]); | |||
}); | |||
</code> | |||
[[File:Heartsla-tpl7.png]] | |||
Explanations: | |||
* We go over each player and create component called LineStock to represent player tableau, it will hold a single card | |||
* We assign this into tableauStocks map indexed by player id to use later | |||
* Finally we add a fake card into that stock just to see something | |||
Stock control can handle clicking on items and forms the selection. You can immediately react to selection | Stock control can handle clicking on items and forms the selection. You can immediately react to selection | ||
or you can query it later; for example when user presses some other button. | or you can query it later; for example when user presses some other button. | ||
Let's hook it up. Add this in the setup method in .js file, | Let's hook it up. Add this in the setup method in .js file, before // TODO: fix handStock: | ||
this.handStock.setSelectionMode("single"); | |||
this.handStock.onCardClick = (card) => { | |||
alert("boom!"); | |||
}; | |||
Reload the game and click on one of the two Cards in your hand. You should get "boom". | |||
We will stop for now with client because we need to code some server stuff. | |||
== Game Database and Game Initialization == | |||
Next, we will design the game data and set up a new game on the server. We need to define our cards with ItemManager . | |||
==== Item model and database schema ==== | |||
The framework's [[ItemManager]] component can create and manage a game item, like cards, based on a PHP class we'll declare to describe the card(item). This lets us work with typed Card objects and avoids writing SQL for card operations. | |||
Leave the card-table example in '''dbmodel.sql''' commented out. ItemManager will create the table when we call <code>initDb()</code>. | |||
Create '''modules/php/Card.php''': | |||
<pre> | <pre> | ||
<?php | |||
declare(strict_types=1); | |||
namespace Bga\Games\HeartsFIXME; | |||
use Bga\GameFramework\Components\ItemManager\Item; | |||
use Bga\GameFramework\Components\ItemManager\ItemField; | |||
use Bga\GameFramework\Components\ItemManager\ItemFieldKind; | |||
#[Item('card')] | |||
class Card | |||
{ | |||
#[ItemField(kind: ItemFieldKind::ID)] | |||
public int $id; | |||
#[ItemField(kind: ItemFieldKind::LOCATION, locationIndex: 0)] | |||
public string $location; | |||
#[ItemField(kind: ItemFieldKind::LOCATION, locationIndex: 1)] | |||
public ?int $location_arg; | |||
#[ItemField(kind: ItemFieldKind::ORDER)] | |||
public int $order; | |||
#[ItemField] | |||
public int $suit; | |||
#[ItemField] | |||
public int $value; | |||
} | |||
</pre> | </pre> | ||
The attributes map the Card properties to the generated table: | |||
* <code>id</code>: the unique, automatically generated card id; | |||
* <code>suit</code>: the suit, from 1 to 4 (Spades, Hearts, Clubs, Diamonds); | |||
* <code>value</code>: the value, from 2 to 14 (2 to Ace); | |||
* <code>location</code> and <code>location_arg</code>: where the card is and, when relevant, the player id; | |||
* <code>order</code>: the ordering value used when shuffling and drawing cards. | |||
Now | Now create '''modules/php/CardManager.php'''. This class keeps all card storage operations in one place and exposes methods named for our game: | ||
<pre> | |||
<?php | |||
declare(strict_types=1); | |||
namespace Bga\Games\HeartsFIXME; | |||
use Bga\GameFramework\Components\ItemManager\ItemLocation; | |||
use Bga\GameFramework\Components\ItemManager\ItemManager; | |||
use Bga\GameFramework\Helpers\Collection; | |||
class CardManager | |||
{ | |||
public ItemManager $cards; | |||
public function __construct(private Game $game) | |||
{ | |||
$this->cards = $game->bga->itemManagerFactory->createItemManager( | |||
Card::class, | |||
locations: [ | |||
...ItemLocation::getDefaults(), | |||
new ItemLocation('cardsontable'), | |||
new ItemLocation('cardswon'), | |||
], | |||
); | |||
} | |||
public function initDb(): void | |||
{ | |||
$this->cards->initDb(); | |||
} | |||
public function setup(): void | |||
{ | |||
$cards = []; | |||
foreach ($this->game->card_types['suites'] as $suit => $suitInfo) { | |||
foreach ($this->game->card_types['types'] as $value => $valueInfo) { | |||
$cards[] = [ | |||
'location' => 'deck', | |||
'suit' => $suit, | |||
) | 'value' => $value, | ||
]; | |||
} | |||
} | |||
$this->cards->createItems($cards); | |||
} | |||
/** @return Collection<Card> */ | |||
public function getPlayerHand(?int $playerId): Collection | |||
{ | |||
return $this->cards->getItemsInLocation(['hand', $playerId]); | |||
} | |||
/** @return Collection<Card> */ | |||
public function getCardsOnTable(?int $playerId = null): Collection | |||
{ | |||
return $this->cards->getItemsInLocation(['cardsontable', $playerId]); | |||
} | |||
/** @return Collection<Card> */ | |||
public function getCardsWon(?int $playerId = null): Collection | |||
{ | |||
return $this->cards->getItemsInLocation(['cardswon', $playerId]); | |||
} | |||
} | |||
</pre> | |||
<code>ItemLocation::getDefaults()</code> provides standard locations such as <code>deck</code> and <code>hand</code>. We add the two locations we will need for Hearts game : cards on table are the cards being played, and card won are the collected cards by a player who win a trick. A null player id asks for cards in that location regardless of its second location argument. | |||
Next we | === Game State Variables === | ||
Next we get into the Game class in '''modules/php/Game.php'''. Add a CardManager property before the constructor: | |||
<pre> | <pre> | ||
public CardManager $cardManager; | |||
</pre> | </pre> | ||
Then initialize it at the end of the constructor, after <code>initGameStateLabels</code>: | |||
<pre> | |||
public function __construct() | |||
{ | |||
parent::__construct(); | |||
$this->initGameStateLabels([ | |||
'trick_color' => 11, | |||
]); | |||
$this->cardManager = new CardManager($this); | |||
} | |||
</pre> | |||
Here we initialize <code>trick_color</code>, an integer stored in the database. Values 1 to 4 identify the lead suit of the current trick; 0 means that the trick has not started. | |||
Since ItemManager owns the card table, initialize that table at the very beginning of <code>setupNewGame</code>: | |||
<pre> | |||
protected function setupNewGame($players, $options = []) | |||
{ | |||
$this->cardManager->initDb(); | |||
// Keep the rest of the template setup here. | |||
</pre> | |||
Because the item model changed the database schema, stop any existing game before testing this code. Then start a new game and make sure it loads. | |||
=== Game Setup === | |||
The <code>setupNewGame</code> method is called once when the game is created. Keep the template code that sets up players. | |||
After the comment <code>// Init global values with their initial values</code>, initialize our state variable: | |||
<pre> | |||
// Set current trick color to zero (= no trick color) | |||
$this->setGameStateInitialValue('trick_color', 0); | |||
</pre> | |||
We also need static information describing the suits and values. Add these lines in the Game constructor: | |||
<pre> | <pre> | ||
/ | $this->card_types = [ | ||
'suites' => [ | |||
1 => ['name' => clienttranslate('Spade')], | |||
2 => ['name' => clienttranslate('Heart')], | |||
3 => ['name' => clienttranslate('Club')], | |||
4 => ['name' => clienttranslate('Diamond')], | |||
], | |||
'types' => [ | |||
2 => ['name' => '2'], | |||
3 => ['name' => '3'], | |||
4 => ['name' => '4'], | |||
5 => ['name' => '5'], | |||
6 => ['name' => '6'], | |||
7 => ['name' => '7'], | |||
8 => ['name' => '8'], | |||
9 => ['name' => '9'], | |||
10 => ['name' => '10'], | |||
11 => ['name' => clienttranslate('J')], | |||
12 => ['name' => clienttranslate('Q')], | |||
13 => ['name' => clienttranslate('K')], | |||
14 => ['name' => clienttranslate('A')], | |||
], | |||
]; | |||
</pre> | |||
Also declare this property in the Game class: | |||
<pre> | |||
public array $card_types; | |||
</pre> | </pre> | ||
If a value is sent to the client in a notification, use untranslated strings. <code>clienttranslate</code> marks a value for translation without translating it on the PHP server. See [[Translations]] for details. | |||
Now create the 52 Card items. Add this after <code>// TODO: Setup the initial game situation here.</code>: | |||
<pre> | <pre> | ||
// Create cards | |||
$this->cardManager->setup(); | |||
</pre> | </pre> | ||
==== Dealing Cards ==== | |||
For the moment, deal the cards directly in <code>cardManager::setup</code> so we can test the interface before building the state machine: | |||
<pre> | <pre> | ||
// Shuffle deck | |||
$this->cards->shuffle('deck'); | |||
); | |||
$this-> | // Deal 13 cards to each player | ||
$players = $this->game->loadPlayersBasicInfos(); | |||
foreach ($players as $playerId => $player) { | |||
$this->cards->pickItems( | |||
13, | |||
['deck'], | |||
['hand', (int)$playerId], | |||
); | |||
} | |||
</pre> | </pre> | ||
Later, card dealing will move to CardManager and the NewHand state. | |||
side | <div style="outline: 3px solid #3178C6; padding: 5px;margin: 5px;"><span style="background: #3178C6; color: white; margin-right: 5px;">TS</span>In <code>src/ts/types.d.ts</code>, define Card fields with their server-side types. In particular, ids, suits, values, and player location arguments are numbers: | ||
<pre> | |||
interface Card { | |||
id: number; | |||
location: string; | |||
location_arg: number | null; | |||
order: number; | |||
suit: number; | |||
value: number; | |||
} | |||
</pre> | |||
</div> | |||
== Full | ==Full Game Model Synchronization== | ||
Now at any point in the game we need to make sure that database information can be reflected back in the UI, so we must fix the '''getAllDatas''' function | Now at any point in the game we need to make sure that database information can be reflected back in the UI, so we must fix the '''getAllDatas''' function | ||
to return all possible data we need to reconstruct the game. This is in the Game.php file. The template for getAllDatas() already takes care of player info. Let's just | to return all possible data we need to reconstruct the game. This is in the Game.php file. | ||
=== Player's Hand === | |||
The template for getAllDatas() already takes care of player info. Let's just | |||
add hand and tableau data before we return a result. | add hand and tableau data before we return a result. | ||
<pre> | <pre> | ||
// Cards in player hand | |||
$result['hand'] = $this->cardManager->getPlayerHand($currentPlayerId)->values(); | |||
// Cards played on the table | |||
$result['cardsontable'] = $this->cardManager->getCardsOnTable()->values(); | |||
</pre> | </pre> | ||
Now on the client side we should display this data, so in your .js file in the setup function (which is the receiver of getAllDatas) replace our hack of putting | Now on the client side we should display this data, so in your game.js file in the setup function (which is the receiver of getAllDatas), find // TODO: fix handStock | ||
and replace our hack of putting cards directly into the hand with: | |||
<pre> | <pre> | ||
// Cards in player's hand | |||
this.handStock.addCards(this.gamedatas.hand); | |||
</pre> | </pre> | ||
This adds every serialized Card object returned by ItemManager to the hand stock. | |||
At this point, you have to RESTART a game and each player should see their hand! | |||
At any point if code does not work on clinet side, add command "debugger;" in the code. In browser press F12 to get dev tools, then reload. | |||
You will hit breakpoint and can you in browser debugger. Don't forget to remove debugger; code after. | |||
=== Cards on Table === | |||
Now lets fix out tableau, find comment in setup method of .js file // TODO: fix tableau | |||
and remove stock.addCards... from that loop. But right after this add | |||
// Cards played on table | |||
for (i in this.gamedatas.cardsontable) { | |||
var card = this.gamedatas.cardsontable[i]; | |||
var player_id = card.location_arg; | |||
this.tableauStocks[player_id].addCard(card); | |||
} | |||
If you reload now you can see nothing on the table. | |||
Next, we will hook-up clicking on card and test if our animation. | |||
Find the "boom" we put in the click handler. Replace with this | |||
this.handStock.onCardClick = (card) => { | |||
this.tableauStocks[card.location_arg].addCard(card); | |||
}; | |||
Now if you reload you should be able to click on card from your hand and see it moving, | |||
you can click on few cards this way. When you done enjoying the animation, press F5 to get your hand back. | |||
[[File:Heartsla-sync.png]] | |||
==State Machine== | |||
Stop the game. We are about to work on the game logic. | |||
You already read [http://www.slideshare.net/boardgamearena/bga-studio-focus-on-bga-game-state-machine Focus on BGA game state machine], so you know that this is the heart of your game logic. | |||
Note: ignore all the source snippets in this presentation, as framework changed, just note the concepts. | |||
Here are the states we need to build (excluding two more states we will add later to handle the exchange of cards at the beginning of the rounds): | |||
*Cards are dealt to all players (lets call it "NewHand") | |||
*Player start or respond to played card ("PlayerTurn") | |||
*Game control is passed to next player or trick is ended ("NextPlayer") | |||
*End of hand processing (scoring and check for end of game) ("EndHand") | |||
Note: if you find states.inc.php file in top level directory - delete it now. | |||
=== State Templates === | |||
We will create just barebones state files first: | |||
==== States/NewHand.php ==== | |||
Let's create our first state - "NewHand". Create a new file under "module/php/States" and name it "'''NewHand.php'''". | |||
<pre> | <pre> | ||
<?php | |||
declare(strict_types=1); | |||
namespace Bga\Games\HeartsFIXME\States; | |||
use Bga\Games\HeartsFIXME\Game; | |||
use Bga\GameFramework\StateType; | |||
use Bga\GameFramework\States\GameState; | |||
class NewHand extends GameState | |||
{ | |||
public function __construct(protected Game $game) | |||
{ | |||
parent::__construct( | |||
$game, | |||
id: 2, // the idea of the state | |||
type: StateType::GAME, // This type means that no player is active, and the game will automatically progress | |||
updateGameProgression: true, // entering this state can update the progress bar of the game | |||
); | |||
} | |||
// The action we do when entering the state | |||
public function onEnteringState() | |||
{ | |||
return PlayerTurn::class; | |||
} | |||
} | |||
</pre> | </pre> | ||
You can read more about it here: [[State classes: State directory]] | |||
If you use IDE you see few errors: | |||
* First there is no Bga\Games\HeartsFIXME\Game - that is because you games is not called HeartsFIXME, is something like HeartsFooBar - so you change this and namespace to your game name. | |||
* Second it will compain abot NewTrick class - it does not exist yet | |||
Let's implement the other states: | |||
==== States/PlayerTurn.php ==== | |||
If file exists replace its content. | |||
This action is different because it has an action a player must take. Read the comments in the code below to understand the syntax: | |||
<pre> | <pre> | ||
<?php | |||
declare(strict_types=1); | |||
namespace Bga\Games\HeartsFIXME\States; | |||
use Bga\Games\HeartsFIXME\Game; | |||
use Bga\GameFramework\StateType; | |||
use Bga\GameFramework\States\PossibleAction; | |||
use Bga\GameFramework\States\GameState; | |||
use Bga\GameFramework\UserException; | |||
class PlayerTurn extends GameState | |||
{ | |||
public function __construct(protected Game $game) | |||
{ | |||
parent::__construct( | |||
$game, | |||
id: 31, | |||
type: StateType::ACTIVE_PLAYER, // This state type means that one player is active and can do actions | |||
} | description: clienttranslate('${actplayer} must play a card'), // We tell OTHER players what they are waiting for | ||
descriptionMyTurn: clienttranslate('${you} must play a card'), // We tell the ACTIVE player what they must do | |||
// We suround the code with clienttranslate() so that the text is sent to the client for translation (this will enable the game to support other languages) | |||
); | |||
} | |||
/ | #[PossibleAction] // a PHP attribute that tells BGA "this method describes a possible action that the player could take", so that you can call that action from the front (the client) | ||
public function actPlayCard(int $cardId, int $activePlayerId) | |||
{ | |||
// TODO: implement logic | |||
return NextPlayer::class; // after the action, we move to the next player | |||
} | |||
public function zombie(int $playerId) | |||
{ | |||
// We must implement this so BGA can auto play in the case a player becomes a zombie, but for this tutorial we won't handle this case | |||
throw new UserException('Not implemented: zombie for player ${player_id}'); | |||
} | |||
} | } | ||
</pre> | </pre> | ||
You would also notice the "zombie" method. This would allow BGA to auto-player for the player if they became inactive. This is mandatory, but we will implement this later. | |||
==== States/NextPlayer.php ==== | |||
This state have a couple of different options for what would be the next state: | |||
* If not all players played a card in the current trick - we need to go to '''PlayerTurn''' (for the next player) | |||
* If all players finished the trick but still have cards in their hand - we need to go to '''PlayerTurn''' | |||
* If this is the last trick (no more cards in end) and it's finished, we need to go to '''EndHand''' | |||
We will implement this logic later. For now let's return '''PlayerTurn''' (see onEnteringState). | |||
<pre> | <pre> | ||
<?php | |||
declare(strict_types=1); | |||
namespace Bga\Games\HeartsFIXME\States; | |||
use Bga\GameFramework\StateType; | |||
use Bga\Games\HeartsFIXME\Game; | |||
use Bga\GameFramework\States\GameState; | |||
class NextPlayer extends GameState | |||
{ | |||
public function __construct(protected Game $game) | |||
{ | |||
parent::__construct( | |||
$game, | |||
id: 32, | |||
type: StateType::GAME, | |||
); | |||
} | |||
public function onEnteringState() | |||
{ | |||
return PlayerTurn::class; | |||
} | |||
} | |||
</pre> | </pre> | ||
==== States/EndHand.php ==== | |||
Here too we will have two options for transition, either we play another hand ('''NewHand''') or we finish the game (a reserved id for finishing the game is '''99'''). | |||
We | We will implement this logic later. For now let's return '''NewHand''' (see onEnteringState). | ||
<pre> | <pre> | ||
<?php | |||
declare(strict_types=1); | |||
namespace Bga\Games\HeartsFIXME\States; | |||
use Bga\GameFramework\StateType; | |||
use Bga\Games\HeartsFIXME\Game; | |||
use Bga\GameFramework\States\GameState; | |||
class EndHand extends GameState | |||
{ | |||
public function __construct(protected Game $game) | |||
{ | |||
parent::__construct( | |||
$game, | |||
id: 40, | |||
type: StateType::GAME, | |||
description: "", | |||
); | |||
} | |||
public function onEnteringState() | |||
{ | |||
// TODO: implement logic | |||
return NewHand::class; | |||
} | |||
} | |||
</pre> | |||
Check again that no HeartsFIXME left in the code, if yes replace with game name. | |||
Remove EndScore.php - don't need it. | |||
Don't start the game yet, it won't load, we have to clean up bunch of template code in the .js | |||
=== Test Your Game is not broken === | |||
We changed state related logic, so we need to restart the game. If the game starts without error we are good. We won't be able to test the interactions yet because we need to implement the client side. | |||
Think the following is from the old framework and no longer relevant. | |||
<s>Since we added bunch of different states we need to remove some more templace code, in .js file find onUpdateActionButtons, and remove all functional code, leaving just this</s><pre> | |||
onUpdateActionButtons: function (stateName, args) { | |||
console.log("onUpdateActionButtons: " + stateName, args); | |||
); | if (this.isCurrentPlayerActive()) { | ||
switch (stateName) { | |||
case "playerTurn": | |||
break; | |||
} | |||
} | |||
}, | |||
</pre> | </pre> | ||
=== State Logic === | |||
Now if you RESTART the game, it should not crash and you see 13 cards in your hand | |||
==== New Hand ==== | |||
We need to: | |||
# Move all cards to the deck | |||
# Shuffle the cards | |||
# Deal the cards to the players | |||
Add these methods to '''CardManager.php''': | |||
<pre> | <pre> | ||
function | public function resetDeck(): void | ||
{ | |||
$this->cards->moveAllItemsInLocation(null, 'deck'); | |||
$this->cards->shuffle('deck'); | |||
} | |||
public function dealNewHands(): void | |||
{ | |||
$playerIds = array_keys($this->game->loadPlayersBasicInfos()); | |||
foreach ($playerIds as $playerId) { | |||
$cards = $this->cards->pickItems( | |||
13, | |||
['deck'], | |||
['hand', (int)$playerId], | |||
); | |||
$this->game->bga->notify->player( | |||
(int)$playerId, | |||
'newHand', | |||
'', | |||
['cards' => $cards], | |||
); | |||
} | } | ||
} | |||
</pre> | </pre> | ||
Then implement '''NewHand.php''' with those game-specific operations: | |||
<pre> | |||
// The action we do when entering the state | |||
public function onEnteringState() | |||
{ | |||
// Take back all cards, shuffle the deck, and deal new hands | |||
$this->game->cardManager->resetDeck(); | |||
$this->game->cardManager->dealNewHands(); | |||
// Reset trick color | |||
$this->game->setGameStateValue('trick_color', 0); | |||
// FIXME: first player is the one with the 2 of Clubs | |||
$firstPlayer = (int)$this->game->getActivePlayerId(); | |||
$this->game->gamestate->changeActivePlayer($firstPlayer); | |||
return PlayerTurn::class; | |||
} | |||
</pre> | |||
==== Next Player ==== | |||
Here we can handle the logic of what is the next state we need to move to:<pre> | |||
public function onEnteringState() | |||
{ | |||
$game = $this->game; | |||
// Active next player OR end the trick and go to the next trick OR end the hand | |||
if ($game->cardManager->getCardsOnTable()->count() == 4) { | |||
// This is the end of the trick | |||
// Select the winner | |||
$best_value_player_id = $game->activeNextPlayer(); // TODO figure out winner of trick | |||
// Move all cards to "cardswon" of the given player | |||
$game->cardManager->cards->moveAllItemsInLocation( | |||
['cardsontable'], | |||
['cardswon', $best_value_player_id], | |||
); | |||
if ($game->cardManager->getPlayerHand(null)->isEmpty()) { | |||
$this-> | // End of the hand | ||
return EndHand::class; | |||
} else { | |||
// End of the trick | |||
// Reset trick suite to 0 | |||
$this->game->setGameStateValue('trick_color', 0); | |||
return PlayerTurn::class; | |||
} | |||
} else { | |||
// Standard case (not the end of the trick) | |||
// => just active the next player | |||
$player_id = $game->activeNextPlayer(); | |||
$game->giveExtraTime($player_id); | |||
return PlayerTurn::class; | |||
} | } | ||
} | |||
</pre>'''Important''': All state actions game or player must return the next state transition (or thrown exception). | |||
==== Player Turn ==== | |||
We will not implement this yet, but we can throw an exception to check that the interaction is working properly. | |||
<pre> | <pre> | ||
#[PossibleAction] // a PHP attribute that tells BGA "this method describes a possible action that the player could take", so that you can call that action from the front (the client) | |||
public function actPlayCard(int $cardId, int $activePlayerId) | |||
{ | |||
} | throw new UserException("Not implemented: $activePlayerId played card $cardId"); | ||
return NextPlayer::class; // after the action, we move to the next player | |||
} | |||
</pre> | </pre> | ||
== Client - Server | ==Client - Server Interactions== | ||
Now to implement things for real we have hook UI actions to ajax calls, and process notifications sent by the server. | Now to implement things for real we have hook UI actions to ajax calls, and process notifications sent by the server. | ||
| Line 819: | Line 1,063: | ||
notification in response, client hooks animations to server notification. | notification in response, client hooks animations to server notification. | ||
So in .js code replace | So in .js code replace find out handStock.onCardClick and replace the handler to | ||
<pre> | <pre> | ||
this.handStock.onCardClick = (card) => { | |||
console.log("onCardClick : card ", card); | |||
console.log("onCardClick : namestate ", this.gamedatas.gamestate.name); | |||
if (!card) return; // hmm - should never happen | |||
switch (this.gamedatas.gamestate.name) { | |||
case "PlayerTurn": | |||
// Can play a card | |||
this.bga.actions.performAction("actPlayCard", { cardId: card.id }); | |||
break; | |||
case "GiveCards": | |||
// Can give cards TODO | |||
break; | |||
default: { | |||
this.handStock.unselectAll(); | |||
break; | |||
} | |||
} | |||
}; | |||
</pre> | </pre> | ||
Now when you click on card you should get a server response: Not implemented... | Now reload and when you click on card you should get a server response: Not implemented... | ||
Keep the storage code in CardManager. Add this method to '''CardManager.php''': | |||
<pre> | <pre> | ||
public function playCard(int $playerId, int $cardId): void | |||
{ | |||
/** @var Card|null $currentCard */ | |||
$currentCard = $this->cards->getItemById($cardId); | |||
if ($currentCard === null) { | |||
throw new \Bga\GameFramework\VisibleSystemException('Unknown card'); | |||
} | } | ||
$this->cards->moveItem($currentCard, ['cardsontable', $playerId]); | |||
// Remember the lead suit | |||
if (!$this->game->getGameStateValue('trick_color')) { | |||
$this->game->setGameStateValue('trick_color', $currentCard->suit); | |||
} | |||
$this->game->bga->notify->all( | |||
'playCard', | |||
clienttranslate('${player_name} plays ${value_displayed} ${color_displayed}'), | |||
[ | |||
'i18n' => ['color_displayed', 'value_displayed'], | |||
'card' => $currentCard, | |||
'player_id' => $playerId, | |||
'player_name' => $this->game->getPlayerNameById($playerId), | |||
'value_displayed' => $this->game->card_types['types'][$currentCard->value]['name'], | |||
'color_displayed' => $this->game->card_types['suites'][$currentCard->suit]['name'], | |||
], | |||
); | |||
} | |||
</pre> | |||
Then the action in '''PlayerTurn.php''' only coordinates rule validation and the transition: | |||
<pre> | <pre> | ||
#[PossibleAction] | |||
public function actPlayCard(int $cardId, int $activePlayerId) | |||
{ | |||
// TODO: check rules here | |||
$this->game->cardManager->playCard($activePlayerId, $cardId); | |||
return NextPlayer::class; | |||
} | |||
</pre> | |||
The ItemManager call moves the typed Card object in the database. CardManager then sends that same object to every client. We will add rule enforcement later. | |||
On the client side .js we have to implement a notification handler to do the animation. Below the '''setupNotification''' method (which you don't need to touch) | |||
after <code>// TODO: from this point and below, you can write your game notifications handling methods</code> | |||
you can put the following code: | |||
<pre> | |||
async notif_newHand(args) { | |||
// We received a new full hand of 13 cards. | |||
this.handStock.removeAll(); | |||
this.handStock.addCards(args.cards); | |||
} | |||
async notif_playCard(args) { | |||
// Play a card on the table | |||
this.tableauStocks[args.player_id].addCards([args.card]); | |||
} | |||
</pre> | </pre> | ||
BGA will automatically bind the event to the '''notif_{eventName} handler''' which will receive the "args" you passed from php. | |||
Refresh the page and try to play a card from the correct player. The card should move to the played area. When you refresh - you should still see the card there. | |||
Swicth to next player using the arrows near player name and play next card. | |||
Just before last card save the game state in "Save 1" slot (buttons in the bottom). These saves game states and you can reload it using "Load 1" later. | |||
It is very handy. | |||
Finish playing the trick. You will notice | |||
after trick is done all cards remains on the table, but if you press F5 they would disappear, this is because | after trick is done all cards remains on the table, but if you press F5 they would disappear, this is because | ||
we updated database to pick-up the cards but did not send notification about it | we updated database to pick-up the cards but did not send notification about it. | ||
Move trick capture and its notifications into CardManager as well. Add this method to '''CardManager.php''': | |||
<pre> | |||
public function winTrick(int $winnerId): void | |||
{ | |||
$cardsOnTable = $this->getCardsOnTable(); | |||
$this->cards->moveAllItemsInLocation( | |||
['cardsontable'], | |||
['cardswon', $winnerId], | |||
); | |||
// Pause before moving the four cards to the winner on each client | |||
$this->game->bga->notify->all('simplePause', '', ['time' => 750]); | |||
$this->game->bga->notify->all( | |||
'giveAllCardsToPlayer', | |||
clienttranslate('${player_name} captures the trick'), | |||
[ | |||
'player_name' => $this->game->getPlayerNameById($winnerId), | |||
'player_id' => $winnerId, | |||
'cards' => $cardsOnTable->values(), | |||
], | |||
); | |||
} | |||
</pre> | |||
In '''NextPlayer.php''', replace the direct <code>moveAllItemsInLocation</code> call with: | |||
<pre> | |||
$game->cardManager->winTrick($best_value_player_id); | |||
</pre> | |||
Now add this handler in the .js file: | |||
<pre> | <pre> | ||
async notif_giveAllCardsToPlayer(args) { | |||
// Move all cards on table to given table, then destroy them | |||
const winner_id = args.player_id; | |||
await this.tableauStocks[winner_id].addCards(args.cards); | |||
} | |||
// TODO: cards has to dissapear after | |||
</pre> | </pre> | ||
Ok we notice that cards that was won bunched up in ugly column and stay on tableau, but they should dissaper after trick is taken. | |||
Now lets fix the ugly stock. We can make tableau a bit bigger to fit 4 cards or we should make cards overlap, later makes more sense since making tableau too big will be ugly. | |||
I could not figure out how to do overlap in LineStock, AI thinks that there is attribute cardOverlap that I can set when creatingt stock, but it does not work on LineStock (as on 1.7), | |||
so lets just add css for this in .css file | |||
<pre> | <pre> | ||
.playertable .ha-card ~ .ha-card { | |||
margin-left: calc(var(--h-card-width) * -0.8); | |||
} | |||
</pre> | </pre> | ||
This uses tilda operator that target the sibling, which is essentially all cards except first. | |||
If you want to test that it works you can reload you test state using Load 1 button to see the finishing of a trick. | |||
Now reload to test the trick taking - it is pretty now . | |||
Final touch, we need card to dissapear into the void. The void we have to create first. | |||
We need to add another node in the dom for that void stock, on server we called location "cardswon" so lets use same name, change the tableau template in .js file to this<pre> | |||
<div id="tableau_${player.id}"/></div> | |||
<div id="cardswon_${player.id}"/></div> | |||
</pre> | </pre> | ||
We added cardswon (and class for tableau just in case we need it later). | |||
Now in setup method of .js file we need to create stock for this location, in the loop where we adding tableau stock and the end of loop add this code: | |||
// add void stock | |||
new BgaCards.VoidStock( | |||
this.cardsManager, | |||
document.getElementById(`cardswon_${player.id}`), | |||
{ | |||
autoPlace: (card) => | |||
card.location === "cardswon" && card.location_arg == player.id, | |||
} | |||
); | |||
If you notice we did not assign this to any variable, this is because we won't need to refer to it, we will use autoPlace feature, where cardManager will know where to place it based on the location from server. | |||
Finally we just have to modify notification handler to add this animation, this is final version (in .js file) | |||
notif_giveAllCardsToPlayer: async function (args) { | |||
// Move all cards on table to given table, then destroy them | |||
const winner_id = args.player_id; | |||
const cards = Array.from(Object.values(args.cards)); | |||
await this.tableauStocks[winner_id].addCards(cards); | |||
await this.cardsManager.placeCards(cards); // auto-placement | |||
}, | |||
So the function is async means it will return Promise. We are doing it so we can wait other animations to complete. | |||
First we adding cards to player tableau, waiting for animation, then adding to our void stock where they are dissapear. | |||
Now after the trick you see all cards move towards the "player's stash". | Now after the trick you see all cards move towards the "player's stash". | ||
The animation is not ideal, so lets at void stock settings to see if can improve it: https://x.boardgamearena.net/data/game-libs/bga-cards/1.0.7/docs/classes/stocks_void-stock.VoidStock.html | |||
Ok, well I could not figure it out, but now you know where docs for these components are. | |||
We will do our CSS hack, in .css add: | |||
.cardswon > .ha-card { | |||
position: absolute; | |||
top: 0 !important; | |||
} | |||
==Zombie turn== | |||
We will implement a zombie function now because a) we have to do it at some point | |||
b) playing 13 cards from 4 players manually to test this game is super annoying - but we can actually re-use this feature to "auto-play" | |||
In '''PlayerTurn.php''', replace the zombie function with this code: | |||
<pre> | <pre> | ||
public function zombie(int $playerId) | |||
// Active next player OR end the trick and go to the next trick OR end the hand | { | ||
// Auto-play a random card from the player's hand | |||
$cardsInHand = $this->game->cardManager->getPlayerHand($playerId); | |||
if (!$cardsInHand->isEmpty()) { | |||
$cardToPlay = $cardsInHand->random(); | |||
$this->game->cardManager->playCard($playerId, $cardToPlay->id); | |||
} | |||
return NextPlayer::class; | |||
} | |||
</pre> | |||
Now, watch this! Click Debug symbol on top bar (bug) and select function "playAutomatically" (this is actually function in your php file! it starts with debug_), | |||
and select number of moves, i.e. 4. | |||
If your zombie function works correctly you will see player play automatically. To play whole hand it will be 52 moves (13*4). | |||
} | |||
==Scoring and End of game handling== | |||
Now we should calculate scoring, which means determining who won each trick. CardManager::playCard already stores the lead suit in <code>trick_color</code>. Replace the TODO winner logic in '''NextPlayer.php''' with: | |||
<pre> | |||
// Active next player OR end the trick and go to the next trick OR end the hand | |||
if ($game->cardManager->getCardsOnTable()->count() == 4) { | |||
$cardsOnTable = $game->cardManager->getCardsOnTable(); | |||
$bestValue = 0; | |||
$winnerId = null; | |||
$currentTrickColor = $game->getGameStateValue('trick_color'); | |||
foreach ($cardsOnTable as $card) { | |||
if ( | |||
$card->suit == $currentTrickColor | |||
&& ($winnerId === null || $card->value > $bestValue) | |||
) { | |||
// location_arg is the player who played this card | |||
$winnerId = $card->location_arg; | |||
$bestValue = $card->value; | |||
} | |||
} | |||
if ($winnerId === null) { | |||
throw new \Bga\GameFramework\VisibleSystemException( | |||
clienttranslate('Error, nobody wins the trick'), | |||
); | |||
} | |||
// The winner starts the next trick | |||
$this->gamestate->changeActivePlayer($winnerId); | |||
// CardManager moves the cards and sends the notification | |||
$game->cardManager->winTrick($winnerId); | |||
// ... keep the end-of-hand logic shown before | |||
</pre> | </pre> | ||
| Line 1,006: | Line 1,334: | ||
Lets score -1 point per heart and call it a day. And game will end when somebody goes -100 or below. | Lets score -1 point per heart and call it a day. And game will end when somebody goes -100 or below. | ||
As UI goes for scoring, the main thing to update is | As UI goes for scoring, the main thing to update is: | ||
* The scoring on the mini boards represented by stars | |||
* Show that in the log. | |||
For a real game, you might consider showing the scoring in a [[Game_interface_logic:_yourgamename.js#Scoring_dialogs|Scoring Dialog]] using tableWindow notification, but this is out of scope of this tutorial. You can do that as homework. | |||
In '''EndHand.php''': | |||
<pre> | <pre> | ||
use Bga\GameFramework\NotificationMessage; // add this to the top of the file, together with the other "use" statements | |||
... | |||
public function onEnteringState() | |||
{ | |||
$game = $this->game; | |||
// Count and score points, then end the game or go to the next hand. | |||
$players = $game->loadPlayersBasicInfos(); | |||
// Gets all "hearts" + queen of spades | |||
$player_to_points = array(); | |||
foreach ($players as $player_id => $player) { | |||
$player_to_points[$player_id] = 0; | |||
} | |||
$cards = $game->cardManager->getCardsWon(); | |||
foreach ($cards as $card) { | |||
$player_id = $card->location_arg; | |||
// Note: 2 = heart | |||
if ($card->suit == 2) { | |||
$player_to_points[$player_id]++; | |||
} | |||
} | |||
// Apply scores to player | |||
foreach ($player_to_points as $player_id => $points) { | |||
if ($points != 0) { | |||
$game->playerScore->inc( | |||
$player_id, | |||
-$points, | |||
new NotificationMessage( | |||
clienttranslate('${player_name} gets ${absInc} hearts and looses ${absInc} points'), | |||
) | |||
); | |||
} | |||
} | |||
///// Test if this is the end of the game | |||
if ($game->playerScore->getMin() <= -100) { | |||
// Trigger the end of the game ! | |||
return 99; // end game | |||
} | |||
return NewHand::class; | |||
} | |||
</pre> | |||
The game should work now. Try to play it! | |||
==Clean Up== | |||
We left some code that comes from template and our first code, we should remove it now. | |||
* In .js file remove debugger; statements if any | |||
* Remove debug code from setupNewGame to deal cards, cards are now dealt in stNewHand state handler | |||
// Shuffle deck | |||
$this->cardManager->cards->shuffle('deck'); | |||
// Deal 13 cards to each player | |||
$players = $this->loadPlayersBasicInfos(); | $players = $this->loadPlayersBasicInfos(); | ||
// | foreach ($players as $playerId => $player) { | ||
$this->cardManager->cards->pickItems(13, ['deck'], ['hand', (int)$playerId]); | |||
} | |||
* Find and remove $playerEnergy variable and it's uses from Game.php (was part of template) | |||
==Rule Enforcements== | |||
Now we have a working game, but there is no rule enforcement. Put card-specific queries in '''CardManager.php'''. | |||
Add these methods: | |||
<pre> | |||
public function brokenHeart(): bool | |||
{ | |||
return $this->getCardsWon()->some( | |||
fn(Card $card) => $card->suit === 2, | |||
); | |||
} | |||
/** @return int[] */ | |||
public function checkPlayableCards(int $playerId): array | |||
{ | |||
$currentTrickColor = $this->game->getGameStateValue('trick_color'); | |||
$cardsOnTable = $this->getCardsOnTable(); | |||
$hand = $this->getPlayerHand($playerId); | |||
$allIds = $hand->pluck('id')->values(); | |||
// A player cannot play twice in the same trick | |||
if ($cardsOnTable->some( | |||
fn(Card $card) => $card->location_arg === $playerId, | |||
)) { | |||
return []; | |||
} | |||
$playedCardsCount = $this->getCardsWon()->count() + $cardsOnTable->count(); | |||
// The 2 of Clubs starts the hand | |||
if ($playedCardsCount === 0) { | |||
$starter = $hand->filter( | |||
fn(Card $card) => $card->suit === 3 && $card->value === 2, | |||
)->first(); | |||
return $starter === null ? [] : [$starter->id]; | |||
} | |||
// First card of a later trick | |||
if (!$currentTrickColor) { | |||
if ($this->brokenHeart()) { | |||
return $allIds; | |||
} | } | ||
$nonHearts = $hand->filter( | |||
fn(Card $card) => $card->suit !== 2, | |||
); | |||
// A player holding only Hearts may lead one | |||
$ | return $nonHearts->isEmpty() | ||
? $allIds | |||
: $nonHearts->pluck('id')->values(); | |||
} | } | ||
// Follow the lead suit if possible | |||
$sameSuit = $hand->filter( | |||
fn(Card $card) => $card->suit === $currentTrickColor, | |||
); | |||
return $sameSuit->isEmpty() | |||
? $allIds | |||
: $sameSuit->pluck('id')->values(); | |||
} | |||
</pre> | </pre> | ||
ItemManager returns a <code>Collection<Card></code>, so we can filter Card objects and extract their ids without querying the generated table directly. | |||
Use this method in '''PlayerTurn.php''': | |||
<pre> | |||
#[PossibleAction] | |||
public function actPlayCard(int $cardId, int $activePlayerId) | |||
{ | |||
$playableCards = $this->game->cardManager->checkPlayableCards($activePlayerId); | |||
if (!in_array($cardId, $playableCards, true)) { | |||
throw new \Bga\GameFramework\UserException( | |||
clienttranslate('You cannot play this card now'), | |||
); | |||
} | |||
$this->game->cardManager->playCard($activePlayerId, $cardId); | |||
$this->game->giveExtraTime($activePlayerId); | |||
return NextPlayer::class; | |||
} | |||
== | public function zombie(int $playerId) | ||
{ | |||
$playableCards = $this->game->cardManager->checkPlayableCards($playerId); | |||
$zombieChoice = $this->getRandomZombieChoice($playableCards); | |||
return $this->actPlayCard($zombieChoice, $playerId); | |||
} | |||
</pre> | |||
* | Finally, send the valid ids privately to the active client with state args: | ||
<pre> | |||
* Start scoring with 100 points each and end when <= 0 | public function getArgs(int $activePlayerId): array | ||
* Fix scoring rules with Q of spades and 26 point reverse scoring | { | ||
return [ | |||
'_private' => [ | |||
* Add statistics | $activePlayerId => [ | ||
* Add card exchange states | 'playableCards' => $this->game->cardManager->checkPlayableCards( | ||
* Add game option to start with 75 points instead of 100 | $activePlayerId, | ||
), | |||
], | |||
], | |||
]; | |||
} | |||
</pre> | |||
On the client side, remove <code>this.handStock.setSelectionMode("single")</code> from <code>setup</code>, then use the state args to make only legal cards selectable: | |||
<pre> | |||
onEnteringState(args, isCurrentPlayerActive) { | |||
console.log( | |||
"Entering state: " + this.bga.states.currentStateName, | |||
args, | |||
); | |||
this.bga.statusBar.setTitle( | |||
isCurrentPlayerActive | |||
? _('${you} must play a card') | |||
: _('${actplayer} must play a card'), | |||
); | |||
switch (this.bga.states.currentStateName) { | |||
case "PlayerTurn": | |||
if (isCurrentPlayerActive) { | |||
const playableCardIds = args._private.playableCards; | |||
const allCards = this.handStock.getCards(); | |||
const playableCards = allCards.filter( | |||
(card) => playableCardIds.includes(card.id), | |||
); | |||
this.handStock.setSelectionMode("single", playableCards); | |||
} | |||
break; | |||
} | |||
} | |||
</pre> | |||
==Fix first player with 2 of clubs== | |||
In '''NewHand.php''', replace the FIXME with: | |||
<pre> | |||
// The player holding the 2 of Clubs starts | |||
$starterCard = $this->game->cardManager | |||
->getPlayerHand(null) | |||
->filter( | |||
fn($card) => $card->suit === 3 && $card->value === 2, | |||
) | |||
->first(); | |||
if ($starterCard === null || $starterCard->location_arg === null) { | |||
throw new \Bga\GameFramework\VisibleSystemException( | |||
'Cannot find the 2 of Clubs', | |||
); | |||
} | |||
$firstPlayer = $starterCard->location_arg; | |||
</pre> | |||
This uses the Card collection and its typed location argument, as the location_arg is the player id when a card is in location 'hand'. | |||
==Spectator support== | |||
A spectator is not a real player but they can watch the game. Most games will require special spectator support, it's one of the steps in the alpha testing checklist. | |||
In this game it's pretty simple, we just hide the hand control in the client | |||
In the .js file in the setup function add this code (after DOM is created): | |||
// Hide hand zone from spectators | |||
if (this.bga.players.isCurrentPlayerSpectator()) | |||
document.getElementById("myhand_wrap").style.display = "none"; | |||
Click Test Spectator at the end of player's panels to test this. | |||
==Improve UI== | |||
We need to fix a few things in the UI still. | |||
===Center Player Areas=== | |||
First let's fix the player tables - to make them centered. | |||
In the .css file find #player-tables and change it to this: | |||
#player-tables { | |||
position: relative; | |||
width: calc(var(--h-tableau-width) * 3.8); | |||
height: calc(var(--h-tableau-height) * 2.4); | |||
margin: auto; // that is a cheap way to make it centered | |||
} | |||
===Better Card Play Animations=== | |||
When another player plays a card it kind of just appears on the tableau, we want to make it look like it's coming from the player hand. | |||
We don't actually have any sort of UI location to have a player hand - but we can either put it on the mini player panel or add it to the bottom of the player areas. | |||
Let's try to put this on the mini player panels. | |||
First we need to add a node in the DOM on the player panel and maybe add an icon to represent the hand. | |||
We have access to some BGA icons and font awesome icons https://fontawesome.com/v4/icons, so we can pick one from there: | |||
In the .js file in the template for player tableau and add this at the end of the forEach body: | |||
<pre> | |||
document.getElementById(`player_panel_content_${player.color}`).innerHTML = | |||
`<div id="otherhand_${player.id}" class="otherhand"><i class="fa fa-window-restore"></i></div>`; | |||
</pre> | |||
In the .js file replace the notif handler for play with this: | |||
<pre> | |||
async notif_playCard(args) { | |||
// Play a card on the table | |||
const playerId = args.player_id; | |||
let settings = {}; | |||
if (playerId != this.player_id) { | |||
settings = { | |||
fromElement: $(`otherhand_${playerId}`), | |||
toPlaceholder: "grow", | |||
}; | |||
} | |||
await this.tableauStocks[playerId].addCard(args.card, settings); | |||
}, | |||
</pre> | |||
What we did here is added a settings parameter for card placement - for cases where it's not our own card to move it from the "hand" area on the mini player board. | |||
Reload and test (use the autoPlay feature to see the animation when the "other" player plays the card). | |||
Now we can also replace the void stock we create with animation to the same "otherhand" area: | |||
<pre> | |||
async notif_giveAllCardsToPlayer(args) { | |||
// Move all cards from notification to dedicated player area and fade out | |||
const playerId = args.player_id; | |||
const cards = Array.from(Object.values(args.cards)); | |||
await this.tableauStocks[playerId].addCards(cards); | |||
await this.tableauStocks[playerId].removeCards(cards, { | |||
fadeOut: true, | |||
slideTo: $(`otherhand_${playerId}`), | |||
}); | |||
}, | |||
</pre> | |||
And in this case we don't really need VoidStock anymore, we can remove it | |||
Delete this | |||
<pre> | |||
// add void stock | |||
new BgaCards.VoidStock( | |||
this.cardsManager, | |||
document.getElementById(`cardswon_${playerId}`), | |||
{ | |||
fadeOut: true, // not working | |||
toPlaceholder: "shrink", // not working | |||
autoPlace: (card) => | |||
card.location === "cardswon" && card.location_arg == playerId, | |||
} | |||
); | |||
</pre> | |||
Also can delete related css and DOM element cardswon. | |||
We can also add this in .css to make this symbol centered: | |||
.otherhand { | |||
position: relative; | |||
margin: auto; | |||
text-align: center; | |||
} | |||
===Card Sorting=== | |||
It would be nice to sort the cards in hand by suit and value. Add a sorter when creating the hand stock: | |||
<pre> | |||
this.handStock = new BgaCards.HandStock( | |||
this.cardsManager, | |||
document.getElementById("myhand"), | |||
{ | |||
sort: BgaCards.sort('suit', 'value'), | |||
}, | |||
); | |||
</pre> | |||
===Tooltips=== | |||
We can add tooltips to cards to show their name. | |||
In the .js file find where created silly tooltip with addTooltipHtml and replace with this: | |||
this.bga.gameui.addTooltipHtml(div.id, | |||
_(this.gamedatas.card_types.types[card.value].name)+ " " + | |||
_(this.gamedatas.card_types.suites[card.suit].name) | |||
); | |||
Now what is this.gamedatas.card_types? Well that is our "material" of the game which is in our case variable in php, we have to send it to client for this to work. | |||
Since it never changes we send it in getAllDatas method, add this at the end before return: | |||
$result['card_types'] = $this->card_types; | |||
Of course this is very basic tooltips and not even needed in this game, but in real game your want tooltips everywhere!!! | |||
Lets add tooltip to our fake hand symbol also (the "otherhand") (in setup method in .js somewhere in forEach loop over players) | |||
// add tooltips to player hand symbol | |||
this.bga.gameui.addTooltipHtml( | |||
`otherhand_${playerId}`, | |||
_("Placeholder for player's hand") | |||
); | |||
==Game progresstion== | |||
In this game it should be easy, we just need to know if somebody close to -100 points! | |||
Find getGameProgression in Game.php and replace with this: | |||
public function getGameProgression() | |||
{ | |||
$min = $this->playerScore->getMin(); | |||
return -1 * $min; // we get close to -100 we get close to 100% game completion | |||
} | |||
==Additional stuff== | |||
The following things were not implemented and you can add them yourself by looking at the code of the original hearts game: | |||
*Mark player who started the hand and add log about what is starting Suite of the trick | |||
*Start scoring with 100 points each and end when <= 0 | |||
*Fix scoring rules with Q of spades and 26 point reverse scoring | |||
*Add statistics | |||
*Add card exchange states | |||
*Add game option to start with 75 points instead of 100 | |||
==After the tutorial== | |||
You might want to check another tutorial, or start working on your first real project! | |||
[[Create a game in BGA Studio: Complete Walkthrough]] | |||
[[Category:Studio]] | [[Category:Studio]] | ||
Latest revision as of 10:42, 9 September 2026
Introduction
Using this tutorial, you can build a complete working game on the BGA environment: Hearts.
Before you read this tutorial, you must:
- Read the overall presentations of the BGA Framework (see here).
- Some-what know the languages used on BGA: PHP, SQL, HTML, CSS, Javascript
- Set up your development environment First Steps with BGA Studio
- As part of setup you have to have access to your ftp home folder in studio, which would have the full 'hearts' game source code. We will be using some resources of this game in this tutorial, so copy it over to local disk if you have not done so.
If you are stuck or have question about this tutorial, post on BGA Developers forum
Note: If you get stuck and you need the source code for this project you can get read only access to project hearts from the Studio projects page : https://studio.boardgamearena.com/projects?game=hearts, only check "Already published" to see the real project. There might be some slight differences as we simplified the tutorial compared to the full production project.
Javascript or Typescript
If you're not familiar with Typescript, we recommend following the tutorial using the Javascript version.
If you're more comfortable with Typescript than plain Javascript, you can try to follow the Typescript version of the tutorial.
Any difference between the Javascript and the Typescript version of this tutorial will be TSoutlined with this color.
Hearts Rules
Hearts is a trick-taking card game for four players where the goal is to score the fewest points. Players aim to avoid taking tricks with heart cards (1 point each) and the Queen of Spades (13 points). Each round, 13 cards are dealt, players pass three cards, and the player with the 2 of Clubs starts the first trick. Play continues clockwise, with players needing to follow suit if they can, and the highest card of the lead suit wins the trick. Hearts cannot be played until they are "broken" by a player who can't follow suit and discards a heart, or by a player leading with a heart after they've been broken.
Create your first game
If you have not already, you have to create a project in BGA Studio. For this tutorial you can create a project heartsYOURNAME where YOURNAME is your developer login name (or shorter version of thereof). You can also re-use the project you have created for the "First Steps" tutorial above.
Go to Manage game page to create your tutorial project.
Note: please do not use the hearts project code as a base. This tutorial assumes you started with a TEMPLATE project with no prior modifications. Using the hearts project as a base will be very confusing and you won't be able to follow all the steps. Also it will not match exactly with this tutorial for different reasons.
With the initial skeleton of code provided, you can already start a game from the BGA Studio.
1. Find and express start the game in turn-based mode with 4 players. Make sure it works. If you want to see the game as 2nd player press red arrow button on the player panel to switch to that player. More details can be found in First_steps_with_BGA_Studio
2. Modify the text in .js file (for example replace "Player zone content goes here" to "Hello"), reload the page in the browser and make sure your ftp sync works as expected. Note: if you have not setup auto-sync do it now, manually copying files is a no-starter.
3. Express stop from settings menu (the gear icon).
Attention!!! Very important note about reloading, if you don't remember this you may spend hours debugging. The browser caches images. If you change any of these files, you have to do "full reload" which is usually Ctrl+F5 (or Ctrl+reload button on browser) not just a regular reload.
If you don't want to use TypeScript in your project, you can delete the package.json, tsconfig.json, rollup.config.mjs files and the src-disabled folder.
Hook version control system
For a real game, or even for this tutorial, we recommend committing the code to version control right from the start. You are going to find yourself in a situation where the game doesn't even start anymore and no way of debugging it, unless you have a way to revert. That is where version control becomes very handy. If you are not familiar with version control (e.g. git) then at least back up your files after each major change. Start now.
Code for this tutorial available is on github: https://github.com/elaskavaia/bga-heartsla
Different revisions represent different steps along the process, starting from original template to a PARTIAL game.
Note: the game was re-written using new template, the old code is in "oldframework" branch. The new template is in main branch.
The real hearts game (that you can play on BGA) can be found in your FTP home folder, after getting read-only access, go to https://studio.boardgamearena.com/projects, select Already Published and find Hearts to get access (It may not match this tutorial as framework diverged since this game was created and it may not have been updated)
Update game infos and box graphics
Even it does nothing yet, always start by making sure the game looks decent in the game selector, meaning it has nice box graphics and its information is correct. For that we need to edit gameinfos.jsonc.
For a real game, you would go to BoardGameGeek, find the game, and use the information from BGG to fill in the gameinfos.
So let's do that. Find "hearts" on BoardGameGeek. (Hint: Original release 1850 :))
You can fill in the year of publishing and bgg id, put Public Domain under publisher (for a real game, leave an empty string so it won't be displayed), and a publisher id of 171 for public domain. And as designer and author you can just put your own name just for fun. Set number of players to 4.
// Game publisher 'publisher' => 'Public Domain',
// Board Game Geek ID of the publisher 'publisher_bgg_id' => 171,
// Players configuration that can be played (ex: 2 to 4 players) 'players' => array( 4 ),
Important step: you have to refresh the information in the Studio website through the control panel. So go to Control Panel -> Manage Games -> heartsYOURNAME and press Reload for 'Reload game informations'.
The next step would be to replace game box with nicer images. This can be done from the Game metadata manager.
Now try to start the game again. If you somehow introduced a syntax error in the gameinfos file it may not work (the game won't start). Always use the "Express Start" button to start the game. You should see a standard state prompt from the template. You should see 4 players on the right: testdude0 .. testdude3. To switch between them press the red arrow button near their names, it will open another tab. This way you don't need to login and logout from multiple accounts!
Note: if you had run the game before with less than 4 players there is a bug that will prevent you from running it with 4 only (if you did not run it before or run it with 4 players as instructed stop reading this note), to workaround revert back to original players array (i.e. 1,2,3,4), reload game options, then create a table with 4 players, exit that game table, then change gameoptions to 4 only as above, reload game options, create table again.
Layout and Graphics
In this section we will do graphics of the game, and main layout of the game.
First copy a sprite with cards image from [1] into img/cards.jpg folder of your project.
Details about images can be found here: Game art: img directory. If you did not setup auto-sync of files, sync the graphics manually with remote folder (re-sync with your workspace).
Edit .js to add some divs to represent player table and hand area, at the beginning of the setup function
setup(gamedatas) {
console.log("Starting game setup");
this.bga.gameArea.getElement().insertAdjacentHTML(
"beforeend",
`
<div id="myhand_wrap" class="whiteblock">
<b id="myhand_label">${_("My hand")}</b>
<div id="myhand">
</div>
</div>
`,
);
// ...
npm i then trigger autobuild with npm run build:ts so the TS you change is built to modules/js/Game.js.
Do not delete the type signature from setup method of the Game.ts file; leave it as it is. Just add the this.bga.gameArea.getElement().insertAdjacentHTML() block as above..
If you refresh you should see now white area with My Hand title.
Now lets add a card into the hand, just so you can feel it. Edit the html snippet we inserted earlier buy adding a line representing a card
...
<div id="myhand">
<div class="fakecard"></div>
</div>
...
Edit .css file, add this code (.css file is empty now, only has comments, just tuck this at the end)
.fakecard {
display: inline-block;
position: relative;
margin-top: 5px;
border-radius: 5%;
width: 100px;
height: 135px;
background-size: calc(100px * 15);
background-image: url('img/cards.jpg'); /* temp hack to see it */
}
When you change existing graphics files remember that you have to FORCE-reload page, i.e. Ctrl-F5, otherwise its cached.
You should see this (more less):
Note: If you don't see the card a) check it was synced to remote folder b) force reload page
Awesome! Now lets do the rest of layout.
Let's complete the game template. You template should have this code, just leave it there
// Example to add a div on the game area
this.bga.gameArea.getElement().insertAdjacentHTML("beforeend",
<div id="player-tables"></div>
`
);
Then change the code following comment "// Setting up player boards" with this
// Setting up player boards
const numPlayers = Object.keys(gamedatas.players).length;
Object.values(gamedatas.players).forEach((player, index) => {
document.getElementById("player-tables").insertAdjacentHTML(
"beforeend",
// we generate this html snippet for each player
`
<div class="playertable whiteblock playertable_${DIRECTIONS[index]}">
<div class="playertablename" style="color:#${player.color};">${player.name}</div>
<div id="tableau_${player.id}"></div>
</div>
`
);
});
What we did is we added a template for every players at the table. Now try to reload you game. Oops! it won't load. This is to teach you how it will look like when you have syntax error in your js file. The game will hang loading at 10% or so. How to know what happened? Open dev tools in browser (usually F12) and navigate to Console tab. You will see a stack trace of where error is. In our case
HeartsFIXME.js:68 Uncaught (in promise) ReferenceError: DIRECTIONS is not defined
In real hearts game they use this direction array to map every player to direction (like North) but its not needed, we can just use player index.
Lets just replace DIRECTIONS[index] with index, i.e
// Add test action buttons in the action status bar, simulating a card click:
playableCardsIds.forEach((cardId) =>
this.bga.statusBar.addActionButton(
_("Play card with id ${card_id}").replace("${card_id}", cardId),
() => this.onCardClick(cardId),
),
);
this.bga.statusBar.addActionButton(
_("Pass"),
() => this.bga.actions.performAction("actPass"),
{ color: "secondary" },
);
Reload. If everything went well you should see this:
These are "tableau" areas for 4 players plus My hand visible only to one player. They are not exactly how we wanted them to be because we did not edit .css yet.
Now edit .css, add these lines after import before our previous definition
:root {
--h-card-width: 100px;
--h-card-height: 135px;
--h-tableau-width: 220px;
--h-tableau-height: 180px;
}
#player-tables {
position: relative;
width: calc(var(--h-tableau-width) * 3.9);
height: calc(var(--h-tableau-height) * 2.4);
}
.playertablename {
font-weight: bold;
}
.playertable {
position: absolute;
text-align: center;
width: var(--h-tableau-width);
height: var(--h-tableau-height);
}
.playertable_0 {
top: 0px;
left: 50%;
margin-left: calc(var(--h-tableau-width) / 2 * -1);
}
.playertable_1 {
left: 0px;
top: 50%;
margin-top: calc(var(--h-tableau-height) / 2 * -1);
}
.playertable_2 {
right: 0px;
top: 50%;
margin-top: calc(var(--h-tableau-height) / 2 * -1);
}
.playertable_3 {
bottom: 0px;
left: 50%;
margin-left: calc(var(--h-tableau-width) / 2 * -1);
}
and delete the following section from game.js as we won't be using it // example of adding a div for each player
document.getElementById("player-tables").insertAdjacentHTML(
"beforeend",
`
<div id="player-table-${player.id}">
<strong>${player.name}</strong>
<div>Player zone content goes here</div>
</div>
`,
);
Now you force Reload and you should see this:
Note: if you did not see changes you may have not force reloaded, force means you use Ctrl+F5 or Ctrl+Shift-R, if you don't "force" browser will use cached version of images! Which is not what you just changed
Here is some explanations about CSS (if you know everything about css already skip this):
- At top we defined some variables for sizes of cards and player "mats" (which we call tableau)
- We trying to layout mats in kind of diamond shape
- We define positions of our elements using top/bottom/left/right style property
- We used standard technique of centering the element which is use 50% for lets say "left", and then shift by half of size of object to actually center it (margin-left). You can remove margins to see how it look if we did not do that
Another Note: In general if you have auto-sync you don't need to reload if you change Game.php file, you need normal reload if you change js, and force reload for images. If you changed state machine or database you likely need to restart the game.
Game Interface with BGA Cards
The BGA framework provides a few out of the box classes to deal with cards. The client side contains a component called BgaCards and it can be used for any dynamic html "pieces" management and animation. On the server side we will use ItemManager, with a small CardManager specific to our game, which we discuss later.
If you open cards.jpg in an image viewer you will see that it is a "sprite" image - a 15x4 grid of images stitched together,
which is a very efficient way to transport images. So we will use the card manager class to mark up these images and create
"card" divs for us and place them on the board.
First, we need to add dependencies in the Game.js file:
const BgaAnimations = await importEsmLib('bga-animations', '1.x');
const BgaCards = await importEsmLib('bga-cards', '1.x');
import { BgaAnimations, BgaCards } from "./libs"; at the very beginning of the Game.ts file <div id="myhand">
<div class="fakecard"></div>
</div>
Then we will add initialization code of bga cards and related component in setup method after the template code (i.e. where we defined the myhand div as this div is referenced by the following code) and before setupNotifications // create the animation manager, and bind it to the `game.bgaAnimationsActive()` function
this.animationManager = new BgaAnimations.Manager({
animationsActive: () => this.bga.gameui.bgaAnimationsActive(),
});
const cardWidth = 100;
const cardHeight = 135;
// create the card manager
this.cardsManager = new BgaCards.Manager({
animationManager: this.animationManager,
type: "ha-card", // the "type" of our cards in css
getId: (card) => card.id,
cardWidth: cardWidth,
cardHeight: cardHeight,
cardBorderRadius: "5%",
setupFrontDiv: (card, div) => {
div.dataset.suit = card.suit; // suit 1..4
div.dataset.value = card.value; // value 2..14
div.style.backgroundPositionX = `calc(100% / 14 * (${card.value} - 2))`; // 14 is number of columns in stock image minus 1
div.style.backgroundPositionY = `calc(100% / 3 * (${card.suit} - 1))`; // 3 is number of rows in stock image minus 1
this.bga.gameui.addTooltipHtml(div.id, `tooltip of ${card.suit}`);
},
});
// create the stock, in the game setup
this.handStock = new BgaCards.HandStock(
this.cardsManager,
document.getElementById("myhand")
);
// TODO: fix handStock
this.handStock.addCards([
{ id: 1, suit: 2, value: 4 }, // 4 of hearts
{ id: 2, suit: 3, value: 11 }, // Jack of clubs
]);
Also we need to add this .css (anywhere), that will map front face of the card to our image (1500% is because this image 15 times bigger than single card on X axis)
.ha-card-front {
background-size: 1500% auto;
background-image: url("img/cards.jpg");
}
Explanations:
- First we created animation manager which will be used later
- Then we define constant with width and height of our cards in pixes
- Then we create the cards manager. We tell it how to get unique id of each card (getId), and how to setup the div representing the front of the card (setupFrontDiv). In this function we set data attributes for suit and value which we will use later, and we set background position to show correct part of sprite image.
- Then we create a hand stock component which will represent player's hand. It is attached to div with id "myhand".
- Finally we add two cards into the hand stock just for testing.
Now if you reload you should see two cards in your hand:
Now we will add the "stock" object that will control player tableau (add in setup function before setupNotification)
// map stocks
this.tableauStocks = [];
Object.values(gamedatas.players).forEach((player, index) => {
// add player tableau stock
this.tableauStocks[player.id] = new BgaCards.LineStock(
this.cardsManager,
document.getElementById(`tableau_${player.id}`)
);
// TODO: fix tableauStocks
this.tableauStocks[player.id].addCards([
{ id: index + 10, suit: index + 1, value: index + 2 },
]);
});
Explanations:
- We go over each player and create component called LineStock to represent player tableau, it will hold a single card
- We assign this into tableauStocks map indexed by player id to use later
- Finally we add a fake card into that stock just to see something
Stock control can handle clicking on items and forms the selection. You can immediately react to selection or you can query it later; for example when user presses some other button.
Let's hook it up. Add this in the setup method in .js file, before // TODO: fix handStock:
this.handStock.setSelectionMode("single");
this.handStock.onCardClick = (card) => {
alert("boom!");
};
Reload the game and click on one of the two Cards in your hand. You should get "boom".
We will stop for now with client because we need to code some server stuff.
Game Database and Game Initialization
Next, we will design the game data and set up a new game on the server. We need to define our cards with ItemManager .
Item model and database schema
The framework's ItemManager component can create and manage a game item, like cards, based on a PHP class we'll declare to describe the card(item). This lets us work with typed Card objects and avoids writing SQL for card operations.
Leave the card-table example in dbmodel.sql commented out. ItemManager will create the table when we call initDb().
Create modules/php/Card.php:
<?php
declare(strict_types=1);
namespace Bga\Games\HeartsFIXME;
use Bga\GameFramework\Components\ItemManager\Item;
use Bga\GameFramework\Components\ItemManager\ItemField;
use Bga\GameFramework\Components\ItemManager\ItemFieldKind;
#[Item('card')]
class Card
{
#[ItemField(kind: ItemFieldKind::ID)]
public int $id;
#[ItemField(kind: ItemFieldKind::LOCATION, locationIndex: 0)]
public string $location;
#[ItemField(kind: ItemFieldKind::LOCATION, locationIndex: 1)]
public ?int $location_arg;
#[ItemField(kind: ItemFieldKind::ORDER)]
public int $order;
#[ItemField]
public int $suit;
#[ItemField]
public int $value;
}
The attributes map the Card properties to the generated table:
id: the unique, automatically generated card id;suit: the suit, from 1 to 4 (Spades, Hearts, Clubs, Diamonds);value: the value, from 2 to 14 (2 to Ace);locationandlocation_arg: where the card is and, when relevant, the player id;order: the ordering value used when shuffling and drawing cards.
Now create modules/php/CardManager.php. This class keeps all card storage operations in one place and exposes methods named for our game:
<?php
declare(strict_types=1);
namespace Bga\Games\HeartsFIXME;
use Bga\GameFramework\Components\ItemManager\ItemLocation;
use Bga\GameFramework\Components\ItemManager\ItemManager;
use Bga\GameFramework\Helpers\Collection;
class CardManager
{
public ItemManager $cards;
public function __construct(private Game $game)
{
$this->cards = $game->bga->itemManagerFactory->createItemManager(
Card::class,
locations: [
...ItemLocation::getDefaults(),
new ItemLocation('cardsontable'),
new ItemLocation('cardswon'),
],
);
}
public function initDb(): void
{
$this->cards->initDb();
}
public function setup(): void
{
$cards = [];
foreach ($this->game->card_types['suites'] as $suit => $suitInfo) {
foreach ($this->game->card_types['types'] as $value => $valueInfo) {
$cards[] = [
'location' => 'deck',
'suit' => $suit,
'value' => $value,
];
}
}
$this->cards->createItems($cards);
}
/** @return Collection<Card> */
public function getPlayerHand(?int $playerId): Collection
{
return $this->cards->getItemsInLocation(['hand', $playerId]);
}
/** @return Collection<Card> */
public function getCardsOnTable(?int $playerId = null): Collection
{
return $this->cards->getItemsInLocation(['cardsontable', $playerId]);
}
/** @return Collection<Card> */
public function getCardsWon(?int $playerId = null): Collection
{
return $this->cards->getItemsInLocation(['cardswon', $playerId]);
}
}
ItemLocation::getDefaults() provides standard locations such as deck and hand. We add the two locations we will need for Hearts game : cards on table are the cards being played, and card won are the collected cards by a player who win a trick. A null player id asks for cards in that location regardless of its second location argument.
Game State Variables
Next we get into the Game class in modules/php/Game.php. Add a CardManager property before the constructor:
public CardManager $cardManager;
Then initialize it at the end of the constructor, after initGameStateLabels:
public function __construct()
{
parent::__construct();
$this->initGameStateLabels([
'trick_color' => 11,
]);
$this->cardManager = new CardManager($this);
}
Here we initialize trick_color, an integer stored in the database. Values 1 to 4 identify the lead suit of the current trick; 0 means that the trick has not started.
Since ItemManager owns the card table, initialize that table at the very beginning of setupNewGame:
protected function setupNewGame($players, $options = [])
{
$this->cardManager->initDb();
// Keep the rest of the template setup here.
Because the item model changed the database schema, stop any existing game before testing this code. Then start a new game and make sure it loads.
Game Setup
The setupNewGame method is called once when the game is created. Keep the template code that sets up players.
After the comment // Init global values with their initial values, initialize our state variable:
// Set current trick color to zero (= no trick color)
$this->setGameStateInitialValue('trick_color', 0);
We also need static information describing the suits and values. Add these lines in the Game constructor:
$this->card_types = [
'suites' => [
1 => ['name' => clienttranslate('Spade')],
2 => ['name' => clienttranslate('Heart')],
3 => ['name' => clienttranslate('Club')],
4 => ['name' => clienttranslate('Diamond')],
],
'types' => [
2 => ['name' => '2'],
3 => ['name' => '3'],
4 => ['name' => '4'],
5 => ['name' => '5'],
6 => ['name' => '6'],
7 => ['name' => '7'],
8 => ['name' => '8'],
9 => ['name' => '9'],
10 => ['name' => '10'],
11 => ['name' => clienttranslate('J')],
12 => ['name' => clienttranslate('Q')],
13 => ['name' => clienttranslate('K')],
14 => ['name' => clienttranslate('A')],
],
];
Also declare this property in the Game class:
public array $card_types;
If a value is sent to the client in a notification, use untranslated strings. clienttranslate marks a value for translation without translating it on the PHP server. See Translations for details.
Now create the 52 Card items. Add this after // TODO: Setup the initial game situation here.:
// Create cards $this->cardManager->setup();
Dealing Cards
For the moment, deal the cards directly in cardManager::setup so we can test the interface before building the state machine:
// Shuffle deck
$this->cards->shuffle('deck');
// Deal 13 cards to each player
$players = $this->game->loadPlayersBasicInfos();
foreach ($players as $playerId => $player) {
$this->cards->pickItems(
13,
['deck'],
['hand', (int)$playerId],
);
}
Later, card dealing will move to CardManager and the NewHand state.
src/ts/types.d.ts, define Card fields with their server-side types. In particular, ids, suits, values, and player location arguments are numbers:
interface Card {
id: number;
location: string;
location_arg: number | null;
order: number;
suit: number;
value: number;
}
Full Game Model Synchronization
Now at any point in the game we need to make sure that database information can be reflected back in the UI, so we must fix the getAllDatas function to return all possible data we need to reconstruct the game. This is in the Game.php file.
Player's Hand
The template for getAllDatas() already takes care of player info. Let's just add hand and tableau data before we return a result.
// Cards in player hand $result['hand'] = $this->cardManager->getPlayerHand($currentPlayerId)->values(); // Cards played on the table $result['cardsontable'] = $this->cardManager->getCardsOnTable()->values();
Now on the client side we should display this data, so in your game.js file in the setup function (which is the receiver of getAllDatas), find // TODO: fix handStock and replace our hack of putting cards directly into the hand with:
// Cards in player's hand
this.handStock.addCards(this.gamedatas.hand);
This adds every serialized Card object returned by ItemManager to the hand stock.
At this point, you have to RESTART a game and each player should see their hand!
At any point if code does not work on clinet side, add command "debugger;" in the code. In browser press F12 to get dev tools, then reload. You will hit breakpoint and can you in browser debugger. Don't forget to remove debugger; code after.
Cards on Table
Now lets fix out tableau, find comment in setup method of .js file // TODO: fix tableau and remove stock.addCards... from that loop. But right after this add
// Cards played on table
for (i in this.gamedatas.cardsontable) {
var card = this.gamedatas.cardsontable[i];
var player_id = card.location_arg;
this.tableauStocks[player_id].addCard(card);
}
If you reload now you can see nothing on the table.
Next, we will hook-up clicking on card and test if our animation.
Find the "boom" we put in the click handler. Replace with this
this.handStock.onCardClick = (card) => {
this.tableauStocks[card.location_arg].addCard(card);
};
Now if you reload you should be able to click on card from your hand and see it moving,
you can click on few cards this way. When you done enjoying the animation, press F5 to get your hand back.
State Machine
Stop the game. We are about to work on the game logic.
You already read Focus on BGA game state machine, so you know that this is the heart of your game logic. Note: ignore all the source snippets in this presentation, as framework changed, just note the concepts.
Here are the states we need to build (excluding two more states we will add later to handle the exchange of cards at the beginning of the rounds):
- Cards are dealt to all players (lets call it "NewHand")
- Player start or respond to played card ("PlayerTurn")
- Game control is passed to next player or trick is ended ("NextPlayer")
- End of hand processing (scoring and check for end of game) ("EndHand")
Note: if you find states.inc.php file in top level directory - delete it now.
State Templates
We will create just barebones state files first:
States/NewHand.php
Let's create our first state - "NewHand". Create a new file under "module/php/States" and name it "NewHand.php".
<?php
declare(strict_types=1);
namespace Bga\Games\HeartsFIXME\States;
use Bga\Games\HeartsFIXME\Game;
use Bga\GameFramework\StateType;
use Bga\GameFramework\States\GameState;
class NewHand extends GameState
{
public function __construct(protected Game $game)
{
parent::__construct(
$game,
id: 2, // the idea of the state
type: StateType::GAME, // This type means that no player is active, and the game will automatically progress
updateGameProgression: true, // entering this state can update the progress bar of the game
);
}
// The action we do when entering the state
public function onEnteringState()
{
return PlayerTurn::class;
}
}
You can read more about it here: State classes: State directory
If you use IDE you see few errors:
- First there is no Bga\Games\HeartsFIXME\Game - that is because you games is not called HeartsFIXME, is something like HeartsFooBar - so you change this and namespace to your game name.
- Second it will compain abot NewTrick class - it does not exist yet
Let's implement the other states:
States/PlayerTurn.php
If file exists replace its content. This action is different because it has an action a player must take. Read the comments in the code below to understand the syntax:
<?php
declare(strict_types=1);
namespace Bga\Games\HeartsFIXME\States;
use Bga\Games\HeartsFIXME\Game;
use Bga\GameFramework\StateType;
use Bga\GameFramework\States\PossibleAction;
use Bga\GameFramework\States\GameState;
use Bga\GameFramework\UserException;
class PlayerTurn extends GameState
{
public function __construct(protected Game $game)
{
parent::__construct(
$game,
id: 31,
type: StateType::ACTIVE_PLAYER, // This state type means that one player is active and can do actions
description: clienttranslate('${actplayer} must play a card'), // We tell OTHER players what they are waiting for
descriptionMyTurn: clienttranslate('${you} must play a card'), // We tell the ACTIVE player what they must do
// We suround the code with clienttranslate() so that the text is sent to the client for translation (this will enable the game to support other languages)
);
}
#[PossibleAction] // a PHP attribute that tells BGA "this method describes a possible action that the player could take", so that you can call that action from the front (the client)
public function actPlayCard(int $cardId, int $activePlayerId)
{
// TODO: implement logic
return NextPlayer::class; // after the action, we move to the next player
}
public function zombie(int $playerId)
{
// We must implement this so BGA can auto play in the case a player becomes a zombie, but for this tutorial we won't handle this case
throw new UserException('Not implemented: zombie for player ${player_id}');
}
}
You would also notice the "zombie" method. This would allow BGA to auto-player for the player if they became inactive. This is mandatory, but we will implement this later.
States/NextPlayer.php
This state have a couple of different options for what would be the next state:
- If not all players played a card in the current trick - we need to go to PlayerTurn (for the next player)
- If all players finished the trick but still have cards in their hand - we need to go to PlayerTurn
- If this is the last trick (no more cards in end) and it's finished, we need to go to EndHand
We will implement this logic later. For now let's return PlayerTurn (see onEnteringState).
<?php
declare(strict_types=1);
namespace Bga\Games\HeartsFIXME\States;
use Bga\GameFramework\StateType;
use Bga\Games\HeartsFIXME\Game;
use Bga\GameFramework\States\GameState;
class NextPlayer extends GameState
{
public function __construct(protected Game $game)
{
parent::__construct(
$game,
id: 32,
type: StateType::GAME,
);
}
public function onEnteringState()
{
return PlayerTurn::class;
}
}
States/EndHand.php
Here too we will have two options for transition, either we play another hand (NewHand) or we finish the game (a reserved id for finishing the game is 99).
We will implement this logic later. For now let's return NewHand (see onEnteringState).
<?php
declare(strict_types=1);
namespace Bga\Games\HeartsFIXME\States;
use Bga\GameFramework\StateType;
use Bga\Games\HeartsFIXME\Game;
use Bga\GameFramework\States\GameState;
class EndHand extends GameState
{
public function __construct(protected Game $game)
{
parent::__construct(
$game,
id: 40,
type: StateType::GAME,
description: "",
);
}
public function onEnteringState()
{
// TODO: implement logic
return NewHand::class;
}
}
Check again that no HeartsFIXME left in the code, if yes replace with game name.
Remove EndScore.php - don't need it.
Don't start the game yet, it won't load, we have to clean up bunch of template code in the .js
Test Your Game is not broken
We changed state related logic, so we need to restart the game. If the game starts without error we are good. We won't be able to test the interactions yet because we need to implement the client side.
Think the following is from the old framework and no longer relevant.
onUpdateActionButtons: function (stateName, args) {
console.log("onUpdateActionButtons: " + stateName, args);
if (this.isCurrentPlayerActive()) {
switch (stateName) {
case "playerTurn":
break;
}
}
},
State Logic
Now if you RESTART the game, it should not crash and you see 13 cards in your hand
New Hand
We need to:
- Move all cards to the deck
- Shuffle the cards
- Deal the cards to the players
Add these methods to CardManager.php:
public function resetDeck(): void
{
$this->cards->moveAllItemsInLocation(null, 'deck');
$this->cards->shuffle('deck');
}
public function dealNewHands(): void
{
$playerIds = array_keys($this->game->loadPlayersBasicInfos());
foreach ($playerIds as $playerId) {
$cards = $this->cards->pickItems(
13,
['deck'],
['hand', (int)$playerId],
);
$this->game->bga->notify->player(
(int)$playerId,
'newHand',
'',
['cards' => $cards],
);
}
}
Then implement NewHand.php with those game-specific operations:
// The action we do when entering the state
public function onEnteringState()
{
// Take back all cards, shuffle the deck, and deal new hands
$this->game->cardManager->resetDeck();
$this->game->cardManager->dealNewHands();
// Reset trick color
$this->game->setGameStateValue('trick_color', 0);
// FIXME: first player is the one with the 2 of Clubs
$firstPlayer = (int)$this->game->getActivePlayerId();
$this->game->gamestate->changeActivePlayer($firstPlayer);
return PlayerTurn::class;
}
Next Player
Here we can handle the logic of what is the next state we need to move to:public function onEnteringState()
{
$game = $this->game;
// Active next player OR end the trick and go to the next trick OR end the hand
if ($game->cardManager->getCardsOnTable()->count() == 4) {
// This is the end of the trick
// Select the winner
$best_value_player_id = $game->activeNextPlayer(); // TODO figure out winner of trick
// Move all cards to "cardswon" of the given player
$game->cardManager->cards->moveAllItemsInLocation(
['cardsontable'],
['cardswon', $best_value_player_id],
);
if ($game->cardManager->getPlayerHand(null)->isEmpty()) {
// End of the hand
return EndHand::class;
} else {
// End of the trick
// Reset trick suite to 0
$this->game->setGameStateValue('trick_color', 0);
return PlayerTurn::class;
}
} else {
// Standard case (not the end of the trick)
// => just active the next player
$player_id = $game->activeNextPlayer();
$game->giveExtraTime($player_id);
return PlayerTurn::class;
}
}
Important: All state actions game or player must return the next state transition (or thrown exception).
Player Turn
We will not implement this yet, but we can throw an exception to check that the interaction is working properly.
#[PossibleAction] // a PHP attribute that tells BGA "this method describes a possible action that the player could take", so that you can call that action from the front (the client)
public function actPlayCard(int $cardId, int $activePlayerId)
{
throw new UserException("Not implemented: $activePlayerId played card $cardId");
return NextPlayer::class; // after the action, we move to the next player
}
Client - Server Interactions
Now to implement things for real we have hook UI actions to ajax calls, and process notifications sent by the server. So previously we hooked playCardOnTable right into js handler which caused client animation, in real game its a two step operation. When user clicks on game element js client sends an ajax call to server, server processes it and updates database, server sends notification in response, client hooks animations to server notification.
So in .js code replace find out handStock.onCardClick and replace the handler to
this.handStock.onCardClick = (card) => {
console.log("onCardClick : card ", card);
console.log("onCardClick : namestate ", this.gamedatas.gamestate.name);
if (!card) return; // hmm - should never happen
switch (this.gamedatas.gamestate.name) {
case "PlayerTurn":
// Can play a card
this.bga.actions.performAction("actPlayCard", { cardId: card.id });
break;
case "GiveCards":
// Can give cards TODO
break;
default: {
this.handStock.unselectAll();
break;
}
}
};
Now reload and when you click on card you should get a server response: Not implemented...
Keep the storage code in CardManager. Add this method to CardManager.php:
public function playCard(int $playerId, int $cardId): void
{
/** @var Card|null $currentCard */
$currentCard = $this->cards->getItemById($cardId);
if ($currentCard === null) {
throw new \Bga\GameFramework\VisibleSystemException('Unknown card');
}
$this->cards->moveItem($currentCard, ['cardsontable', $playerId]);
// Remember the lead suit
if (!$this->game->getGameStateValue('trick_color')) {
$this->game->setGameStateValue('trick_color', $currentCard->suit);
}
$this->game->bga->notify->all(
'playCard',
clienttranslate('${player_name} plays ${value_displayed} ${color_displayed}'),
[
'i18n' => ['color_displayed', 'value_displayed'],
'card' => $currentCard,
'player_id' => $playerId,
'player_name' => $this->game->getPlayerNameById($playerId),
'value_displayed' => $this->game->card_types['types'][$currentCard->value]['name'],
'color_displayed' => $this->game->card_types['suites'][$currentCard->suit]['name'],
],
);
}
Then the action in PlayerTurn.php only coordinates rule validation and the transition:
#[PossibleAction]
public function actPlayCard(int $cardId, int $activePlayerId)
{
// TODO: check rules here
$this->game->cardManager->playCard($activePlayerId, $cardId);
return NextPlayer::class;
}
The ItemManager call moves the typed Card object in the database. CardManager then sends that same object to every client. We will add rule enforcement later.
On the client side .js we have to implement a notification handler to do the animation. Below the setupNotification method (which you don't need to touch)
after // TODO: from this point and below, you can write your game notifications handling methods
you can put the following code:
async notif_newHand(args) {
// We received a new full hand of 13 cards.
this.handStock.removeAll();
this.handStock.addCards(args.cards);
}
async notif_playCard(args) {
// Play a card on the table
this.tableauStocks[args.player_id].addCards([args.card]);
}
BGA will automatically bind the event to the notif_{eventName} handler which will receive the "args" you passed from php.
Refresh the page and try to play a card from the correct player. The card should move to the played area. When you refresh - you should still see the card there. Swicth to next player using the arrows near player name and play next card. Just before last card save the game state in "Save 1" slot (buttons in the bottom). These saves game states and you can reload it using "Load 1" later. It is very handy. Finish playing the trick. You will notice after trick is done all cards remains on the table, but if you press F5 they would disappear, this is because we updated database to pick-up the cards but did not send notification about it.
Move trick capture and its notifications into CardManager as well. Add this method to CardManager.php:
public function winTrick(int $winnerId): void
{
$cardsOnTable = $this->getCardsOnTable();
$this->cards->moveAllItemsInLocation(
['cardsontable'],
['cardswon', $winnerId],
);
// Pause before moving the four cards to the winner on each client
$this->game->bga->notify->all('simplePause', '', ['time' => 750]);
$this->game->bga->notify->all(
'giveAllCardsToPlayer',
clienttranslate('${player_name} captures the trick'),
[
'player_name' => $this->game->getPlayerNameById($winnerId),
'player_id' => $winnerId,
'cards' => $cardsOnTable->values(),
],
);
}
In NextPlayer.php, replace the direct moveAllItemsInLocation call with:
$game->cardManager->winTrick($best_value_player_id);
Now add this handler in the .js file:
async notif_giveAllCardsToPlayer(args) {
// Move all cards on table to given table, then destroy them
const winner_id = args.player_id;
await this.tableauStocks[winner_id].addCards(args.cards);
}
// TODO: cards has to dissapear after
Ok we notice that cards that was won bunched up in ugly column and stay on tableau, but they should dissaper after trick is taken.
Now lets fix the ugly stock. We can make tableau a bit bigger to fit 4 cards or we should make cards overlap, later makes more sense since making tableau too big will be ugly.
I could not figure out how to do overlap in LineStock, AI thinks that there is attribute cardOverlap that I can set when creatingt stock, but it does not work on LineStock (as on 1.7), so lets just add css for this in .css file
.playertable .ha-card ~ .ha-card {
margin-left: calc(var(--h-card-width) * -0.8);
}
This uses tilda operator that target the sibling, which is essentially all cards except first.
If you want to test that it works you can reload you test state using Load 1 button to see the finishing of a trick.
Now reload to test the trick taking - it is pretty now .
Final touch, we need card to dissapear into the void. The void we have to create first.
We need to add another node in the dom for that void stock, on server we called location "cardswon" so lets use same name, change the tableau template in .js file to this <div id="tableau_${player.id}"/></div>
<div id="cardswon_${player.id}"/></div>
We added cardswon (and class for tableau just in case we need it later).
Now in setup method of .js file we need to create stock for this location, in the loop where we adding tableau stock and the end of loop add this code:
// add void stock
new BgaCards.VoidStock(
this.cardsManager,
document.getElementById(`cardswon_${player.id}`),
{
autoPlace: (card) =>
card.location === "cardswon" && card.location_arg == player.id,
}
);
If you notice we did not assign this to any variable, this is because we won't need to refer to it, we will use autoPlace feature, where cardManager will know where to place it based on the location from server. Finally we just have to modify notification handler to add this animation, this is final version (in .js file)
notif_giveAllCardsToPlayer: async function (args) {
// Move all cards on table to given table, then destroy them
const winner_id = args.player_id;
const cards = Array.from(Object.values(args.cards));
await this.tableauStocks[winner_id].addCards(cards);
await this.cardsManager.placeCards(cards); // auto-placement
},
So the function is async means it will return Promise. We are doing it so we can wait other animations to complete. First we adding cards to player tableau, waiting for animation, then adding to our void stock where they are dissapear.
Now after the trick you see all cards move towards the "player's stash". The animation is not ideal, so lets at void stock settings to see if can improve it: https://x.boardgamearena.net/data/game-libs/bga-cards/1.0.7/docs/classes/stocks_void-stock.VoidStock.html Ok, well I could not figure it out, but now you know where docs for these components are. We will do our CSS hack, in .css add:
.cardswon > .ha-card {
position: absolute;
top: 0 !important;
}
Zombie turn
We will implement a zombie function now because a) we have to do it at some point b) playing 13 cards from 4 players manually to test this game is super annoying - but we can actually re-use this feature to "auto-play"
In PlayerTurn.php, replace the zombie function with this code:
public function zombie(int $playerId)
{
// Auto-play a random card from the player's hand
$cardsInHand = $this->game->cardManager->getPlayerHand($playerId);
if (!$cardsInHand->isEmpty()) {
$cardToPlay = $cardsInHand->random();
$this->game->cardManager->playCard($playerId, $cardToPlay->id);
}
return NextPlayer::class;
}
Now, watch this! Click Debug symbol on top bar (bug) and select function "playAutomatically" (this is actually function in your php file! it starts with debug_), and select number of moves, i.e. 4. If your zombie function works correctly you will see player play automatically. To play whole hand it will be 52 moves (13*4).
Scoring and End of game handling
Now we should calculate scoring, which means determining who won each trick. CardManager::playCard already stores the lead suit in trick_color. Replace the TODO winner logic in NextPlayer.php with:
// Active next player OR end the trick and go to the next trick OR end the hand
if ($game->cardManager->getCardsOnTable()->count() == 4) {
$cardsOnTable = $game->cardManager->getCardsOnTable();
$bestValue = 0;
$winnerId = null;
$currentTrickColor = $game->getGameStateValue('trick_color');
foreach ($cardsOnTable as $card) {
if (
$card->suit == $currentTrickColor
&& ($winnerId === null || $card->value > $bestValue)
) {
// location_arg is the player who played this card
$winnerId = $card->location_arg;
$bestValue = $card->value;
}
}
if ($winnerId === null) {
throw new \Bga\GameFramework\VisibleSystemException(
clienttranslate('Error, nobody wins the trick'),
);
}
// The winner starts the next trick
$this->gamestate->changeActivePlayer($winnerId);
// CardManager moves the cards and sends the notification
$game->cardManager->winTrick($winnerId);
// ... keep the end-of-hand logic shown before
The scoring rule in the studio example code is huge multi-page function, for this tutorial we will make simplier. Lets score -1 point per heart and call it a day. And game will end when somebody goes -100 or below.
As UI goes for scoring, the main thing to update is:
- The scoring on the mini boards represented by stars
- Show that in the log.
For a real game, you might consider showing the scoring in a Scoring Dialog using tableWindow notification, but this is out of scope of this tutorial. You can do that as homework.
In EndHand.php:
use Bga\GameFramework\NotificationMessage; // add this to the top of the file, together with the other "use" statements
...
public function onEnteringState()
{
$game = $this->game;
// Count and score points, then end the game or go to the next hand.
$players = $game->loadPlayersBasicInfos();
// Gets all "hearts" + queen of spades
$player_to_points = array();
foreach ($players as $player_id => $player) {
$player_to_points[$player_id] = 0;
}
$cards = $game->cardManager->getCardsWon();
foreach ($cards as $card) {
$player_id = $card->location_arg;
// Note: 2 = heart
if ($card->suit == 2) {
$player_to_points[$player_id]++;
}
}
// Apply scores to player
foreach ($player_to_points as $player_id => $points) {
if ($points != 0) {
$game->playerScore->inc(
$player_id,
-$points,
new NotificationMessage(
clienttranslate('${player_name} gets ${absInc} hearts and looses ${absInc} points'),
)
);
}
}
///// Test if this is the end of the game
if ($game->playerScore->getMin() <= -100) {
// Trigger the end of the game !
return 99; // end game
}
return NewHand::class;
}
The game should work now. Try to play it!
Clean Up
We left some code that comes from template and our first code, we should remove it now.
- In .js file remove debugger; statements if any
- Remove debug code from setupNewGame to deal cards, cards are now dealt in stNewHand state handler
// Shuffle deck
$this->cardManager->cards->shuffle('deck');
// Deal 13 cards to each player
$players = $this->loadPlayersBasicInfos();
foreach ($players as $playerId => $player) {
$this->cardManager->cards->pickItems(13, ['deck'], ['hand', (int)$playerId]);
}
- Find and remove $playerEnergy variable and it's uses from Game.php (was part of template)
Rule Enforcements
Now we have a working game, but there is no rule enforcement. Put card-specific queries in CardManager.php.
Add these methods:
public function brokenHeart(): bool
{
return $this->getCardsWon()->some(
fn(Card $card) => $card->suit === 2,
);
}
/** @return int[] */
public function checkPlayableCards(int $playerId): array
{
$currentTrickColor = $this->game->getGameStateValue('trick_color');
$cardsOnTable = $this->getCardsOnTable();
$hand = $this->getPlayerHand($playerId);
$allIds = $hand->pluck('id')->values();
// A player cannot play twice in the same trick
if ($cardsOnTable->some(
fn(Card $card) => $card->location_arg === $playerId,
)) {
return [];
}
$playedCardsCount = $this->getCardsWon()->count() + $cardsOnTable->count();
// The 2 of Clubs starts the hand
if ($playedCardsCount === 0) {
$starter = $hand->filter(
fn(Card $card) => $card->suit === 3 && $card->value === 2,
)->first();
return $starter === null ? [] : [$starter->id];
}
// First card of a later trick
if (!$currentTrickColor) {
if ($this->brokenHeart()) {
return $allIds;
}
$nonHearts = $hand->filter(
fn(Card $card) => $card->suit !== 2,
);
// A player holding only Hearts may lead one
return $nonHearts->isEmpty()
? $allIds
: $nonHearts->pluck('id')->values();
}
// Follow the lead suit if possible
$sameSuit = $hand->filter(
fn(Card $card) => $card->suit === $currentTrickColor,
);
return $sameSuit->isEmpty()
? $allIds
: $sameSuit->pluck('id')->values();
}
ItemManager returns a Collection<Card>, so we can filter Card objects and extract their ids without querying the generated table directly.
Use this method in PlayerTurn.php:
#[PossibleAction]
public function actPlayCard(int $cardId, int $activePlayerId)
{
$playableCards = $this->game->cardManager->checkPlayableCards($activePlayerId);
if (!in_array($cardId, $playableCards, true)) {
throw new \Bga\GameFramework\UserException(
clienttranslate('You cannot play this card now'),
);
}
$this->game->cardManager->playCard($activePlayerId, $cardId);
$this->game->giveExtraTime($activePlayerId);
return NextPlayer::class;
}
public function zombie(int $playerId)
{
$playableCards = $this->game->cardManager->checkPlayableCards($playerId);
$zombieChoice = $this->getRandomZombieChoice($playableCards);
return $this->actPlayCard($zombieChoice, $playerId);
}
Finally, send the valid ids privately to the active client with state args:
public function getArgs(int $activePlayerId): array
{
return [
'_private' => [
$activePlayerId => [
'playableCards' => $this->game->cardManager->checkPlayableCards(
$activePlayerId,
),
],
],
];
}
On the client side, remove this.handStock.setSelectionMode("single") from setup, then use the state args to make only legal cards selectable:
onEnteringState(args, isCurrentPlayerActive) {
console.log(
"Entering state: " + this.bga.states.currentStateName,
args,
);
this.bga.statusBar.setTitle(
isCurrentPlayerActive
? _('${you} must play a card')
: _('${actplayer} must play a card'),
);
switch (this.bga.states.currentStateName) {
case "PlayerTurn":
if (isCurrentPlayerActive) {
const playableCardIds = args._private.playableCards;
const allCards = this.handStock.getCards();
const playableCards = allCards.filter(
(card) => playableCardIds.includes(card.id),
);
this.handStock.setSelectionMode("single", playableCards);
}
break;
}
}
Fix first player with 2 of clubs
In NewHand.php, replace the FIXME with:
// The player holding the 2 of Clubs starts
$starterCard = $this->game->cardManager
->getPlayerHand(null)
->filter(
fn($card) => $card->suit === 3 && $card->value === 2,
)
->first();
if ($starterCard === null || $starterCard->location_arg === null) {
throw new \Bga\GameFramework\VisibleSystemException(
'Cannot find the 2 of Clubs',
);
}
$firstPlayer = $starterCard->location_arg;
This uses the Card collection and its typed location argument, as the location_arg is the player id when a card is in location 'hand'.
Spectator support
A spectator is not a real player but they can watch the game. Most games will require special spectator support, it's one of the steps in the alpha testing checklist. In this game it's pretty simple, we just hide the hand control in the client
In the .js file in the setup function add this code (after DOM is created):
// Hide hand zone from spectators
if (this.bga.players.isCurrentPlayerSpectator())
document.getElementById("myhand_wrap").style.display = "none";
Click Test Spectator at the end of player's panels to test this.
Improve UI
We need to fix a few things in the UI still.
Center Player Areas
First let's fix the player tables - to make them centered. In the .css file find #player-tables and change it to this:
#player-tables {
position: relative;
width: calc(var(--h-tableau-width) * 3.8);
height: calc(var(--h-tableau-height) * 2.4);
margin: auto; // that is a cheap way to make it centered
}
Better Card Play Animations
When another player plays a card it kind of just appears on the tableau, we want to make it look like it's coming from the player hand. We don't actually have any sort of UI location to have a player hand - but we can either put it on the mini player panel or add it to the bottom of the player areas. Let's try to put this on the mini player panels. First we need to add a node in the DOM on the player panel and maybe add an icon to represent the hand. We have access to some BGA icons and font awesome icons https://fontawesome.com/v4/icons, so we can pick one from there:
In the .js file in the template for player tableau and add this at the end of the forEach body:
document.getElementById(`player_panel_content_${player.color}`).innerHTML =
`<div id="otherhand_${player.id}" class="otherhand"><i class="fa fa-window-restore"></i></div>`;
In the .js file replace the notif handler for play with this:
async notif_playCard(args) {
// Play a card on the table
const playerId = args.player_id;
let settings = {};
if (playerId != this.player_id) {
settings = {
fromElement: $(`otherhand_${playerId}`),
toPlaceholder: "grow",
};
}
await this.tableauStocks[playerId].addCard(args.card, settings);
},
What we did here is added a settings parameter for card placement - for cases where it's not our own card to move it from the "hand" area on the mini player board. Reload and test (use the autoPlay feature to see the animation when the "other" player plays the card).
Now we can also replace the void stock we create with animation to the same "otherhand" area:
async notif_giveAllCardsToPlayer(args) {
// Move all cards from notification to dedicated player area and fade out
const playerId = args.player_id;
const cards = Array.from(Object.values(args.cards));
await this.tableauStocks[playerId].addCards(cards);
await this.tableauStocks[playerId].removeCards(cards, {
fadeOut: true,
slideTo: $(`otherhand_${playerId}`),
});
},
And in this case we don't really need VoidStock anymore, we can remove it Delete this
// add void stock
new BgaCards.VoidStock(
this.cardsManager,
document.getElementById(`cardswon_${playerId}`),
{
fadeOut: true, // not working
toPlaceholder: "shrink", // not working
autoPlace: (card) =>
card.location === "cardswon" && card.location_arg == playerId,
}
);
Also can delete related css and DOM element cardswon.
We can also add this in .css to make this symbol centered:
.otherhand {
position: relative;
margin: auto;
text-align: center;
}
Card Sorting
It would be nice to sort the cards in hand by suit and value. Add a sorter when creating the hand stock:
this.handStock = new BgaCards.HandStock(
this.cardsManager,
document.getElementById("myhand"),
{
sort: BgaCards.sort('suit', 'value'),
},
);
Tooltips
We can add tooltips to cards to show their name. In the .js file find where created silly tooltip with addTooltipHtml and replace with this:
this.bga.gameui.addTooltipHtml(div.id,
_(this.gamedatas.card_types.types[card.value].name)+ " " +
_(this.gamedatas.card_types.suites[card.suit].name)
);
Now what is this.gamedatas.card_types? Well that is our "material" of the game which is in our case variable in php, we have to send it to client for this to work. Since it never changes we send it in getAllDatas method, add this at the end before return:
$result['card_types'] = $this->card_types;
Of course this is very basic tooltips and not even needed in this game, but in real game your want tooltips everywhere!!! Lets add tooltip to our fake hand symbol also (the "otherhand") (in setup method in .js somewhere in forEach loop over players)
// add tooltips to player hand symbol
this.bga.gameui.addTooltipHtml(
`otherhand_${playerId}`,
_("Placeholder for player's hand")
);
Game progresstion
In this game it should be easy, we just need to know if somebody close to -100 points! Find getGameProgression in Game.php and replace with this:
public function getGameProgression()
{
$min = $this->playerScore->getMin();
return -1 * $min; // we get close to -100 we get close to 100% game completion
}
Additional stuff
The following things were not implemented and you can add them yourself by looking at the code of the original hearts game:
- Mark player who started the hand and add log about what is starting Suite of the trick
- Start scoring with 100 points each and end when <= 0
- Fix scoring rules with Q of spades and 26 point reverse scoring
- Add statistics
- Add card exchange states
- Add game option to start with 75 points instead of 100
After the tutorial
You might want to check another tutorial, or start working on your first real project!






