This is a documentation for Board Game Arena: play board games online !
Tutorial gomoku: Розніца паміж версіямі
Няма тлумачэння праўкі |
(Removed navigation - too wide images on this page make problems..) |
||
(Не паказана 44 прамежкавыя версіі 5 удзельнікаў) | |||
Радок 1: | Радок 1: | ||
Here is how your games looks by default when it has just been created : | This tutorial will guide you through the basics of creating a simple game on BGA Studio, through the example of [http://en.wikipedia.org/wiki/Gomoku '''Gomoku'''] (also known as Gobang or Five in a Row). | ||
== You will start from our 'empty game' template == | |||
Here is how your games looks by default when it has just been created: | |||
[[File:Gomoku tuto1.png]] | [[File:Gomoku tuto1.png]] | ||
Радок 7: | Радок 10: | ||
== Setup the board == | == Setup the board == | ||
Gather useful images for the game and edit them as needed. | Gather useful images for the game and edit them as needed. Upload them in the 'img' folder of your SFTP access. | ||
Edit .tpl to add some divs for the board in the HTML. For example: | |||
<pre> | |||
<div id="gmk_game_area"> | |||
<div id="gmk_background"> | |||
<div id="gmk_goban"> | |||
</div> | |||
</div> | |||
</div> | |||
</pre> | |||
Edit .css to set the div sizes and positions and show the image of the board as background. | |||
<pre> | |||
#gmk_game_area { | |||
text-align: center; | |||
position: relative; | |||
} | |||
#gmk_background { | |||
width: 620px; | |||
height: 620px; | |||
position: relative; | |||
display: inline-block; | |||
} | |||
#gmk_goban { | |||
background-image: url( 'img/goban.jpg'); | |||
width: 620px; | |||
height: 620px; | |||
position: absolute; | |||
} | |||
</pre> | |||
[[File:Gomoku tuto2.png]] | [[File:Gomoku tuto2.png]] | ||
Радок 17: | Радок 50: | ||
== Setup the backbone of your game == | == Setup the backbone of your game == | ||
Edit .sql to create a table for intersections. | Edit dbmodel.sql to create a table for intersections. We need coordinates for each intersection and a field to store the color of the stone on this intersection (if any). | ||
<pre> | |||
CREATE TABLE IF NOT EXISTS `intersection` ( | |||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT, | |||
`coord_x` tinyint(2) unsigned NOT NULL, | |||
`coord_y` tinyint(2) unsigned NOT NULL, | |||
`stone_color` varchar(8) NULL, | |||
PRIMARY KEY (`id`) | |||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ; | |||
</pre> | |||
Edit .game.php->setupNewGame() to insert the empty intersections (19x19) with coordinates into the database. | |||
<pre> | |||
// Insert (empty) intersections into database | |||
$sql = "INSERT INTO intersection (coord_x, coord_y) VALUES "; | |||
$values = array(); | |||
for ($x = 0; $x < 19; $x++) { | |||
for ($y = 0; $y < 19; $y++) { | |||
$values[] = "($x, $y)"; | |||
} | |||
} | |||
$sql .= implode( $values, ',' ); | |||
self::DbQuery( $sql ); | |||
</pre> | |||
Edit .game.php->getAllDatas() to retrieve the state of the intersections from the database. | |||
<pre> | |||
// Intersections | |||
$sql = "SELECT id, coord_x, coord_y, stone_color FROM intersection "; | |||
$result['intersections'] = self::getCollectionFromDb( $sql ); | |||
</pre> | |||
Edit .tpl to create a template for intersections. | |||
<pre> | |||
var jstpl_intersection='<div class="gmk_intersection ${stone_type}" id="intersection_${x}_${y}"></div>'; | |||
</pre> | |||
Define the styles for the intersection divs. | |||
<pre> | |||
.gmk_intersection { | |||
width: 30px; | |||
height: 30px; | |||
position: relative; | |||
} | |||
</pre> | |||
Edit .game.php-> | Edit .js->setup() to setup the intersections layer that will be used to get click events and to display the stones. The data you returned in $result['intersections'] in .game.php->getAllDatas() is now available in your .js->setup() in gamedatas.intersections. | ||
<pre> | |||
// Setup intersections | |||
for( var id in gamedatas.intersections ) | |||
{ | |||
var intersection = gamedatas.intersections[id]; | |||
dojo.place( this.format_block('jstpl_intersection', { | |||
x:intersection.coord_x, | |||
y:intersection.coord_y, | |||
stone_type:(intersection.stone_color == null ? "no_stone" : 'stone_' + intersection.stone_color) | |||
} ), $ ( 'gmk_background' ) ); | |||
var x_pix = this.getXPixelCoordinates(intersection.coord_x); | |||
material.inc.php and pass them to your .js | var y_pix = this.getYPixelCoordinates(intersection.coord_y); | ||
this.slideToObjectPos( $('intersection_'+intersection.coord_x+'_'+intersection.coord_y), $('gmk_background'), x_pix, y_pix, 10 ).play(); | |||
if (intersection.stone_color != null) { | |||
// This intersection is taken, it shouldn't appear as clickable anymore | |||
dojo.removeClass( 'intersection_' + intersection.coord_x + '_' + intersection.coord_y, 'clickable' ); | |||
} | |||
} | |||
</pre> | |||
Use some temporary css border-color or background-color and opacity to see the divs and make sure you have them positioned right. | |||
<pre> | |||
.gmk_intersection { | |||
width: 30px; | |||
height: 30px; | |||
position: relative; | |||
background-color: blue; | |||
opacity: 0.3; | |||
} | |||
</pre> | |||
You can declare some constants in material.inc.php and pass them to your .js for easy repositioning (modify constant, refresh). This is especially useful if the same constants have to be used on the server and on the client. | |||
* Declare your constants in material.inc.php (this will be automatically included in your .game.php) | |||
<pre> | |||
$this->gameConstants = array( | |||
"INTERSECTION_WIDTH" => 30, | |||
"INTERSECTION_HEIGHT" => 30, | |||
"INTERSECTION_X_SPACER" => 2.8, // Float | |||
"INTERSECTION_Y_SPACER" => 2.8, // Float | |||
"X_ORIGIN" => 0, | |||
"Y_ORIGIN" => 0, | |||
); | |||
</pre> | |||
* In .game.php->getAllDatas(), add the constants to the result array | |||
// Constants | |||
$result['constants'] = $this->gameConstants; | |||
* In .js constructor, define a class variable for constants | |||
// Game constants | |||
this.gameConstants = null; | |||
* In .js->setup() assign the constants to this variable | |||
this.gameConstants = gamedatas.constants; | |||
* Then use it in your getXPixelCoordinates and getYPixelCoordinates functions | |||
getXPixelCoordinates: function( intersection_x ) | |||
{ | |||
return this.gameConstants['X_ORIGIN'] + intersection_x * (this.gameConstants['INTERSECTION_WIDTH'] + this.gameConstants['INTERSECTION_X_SPACER']); | |||
}, | |||
getYPixelCoordinates: function( intersection_y ) | |||
{ | |||
return this.gameConstants['Y_ORIGIN'] + intersection_y * (this.gameConstants['INTERSECTION_HEIGHT'] + this.gameConstants['INTERSECTION_Y_SPACER']); | |||
}, | |||
Here is what you should get: | |||
[[File:Gomoku tuto3.png]] | [[File:Gomoku tuto3.png]] | ||
Радок 32: | Радок 187: | ||
== Manage states and events == | == Manage states and events == | ||
Define your game states in states.inc.php | Define your game states in states.inc.php. For gomoku we will use 3 states in addition of the predefined states 1 (gameSetup) and 99 (gameEnd). One to play, one to check the end game condition, one to give his turn to the other player if the game is not over. | ||
The first state requires an action from the player, so its type is 'activeplayer'. | |||
The two others are automatic actions for the game, so their type is 'game'. | |||
We will update the progression while checking for the end of the game, so for this state we set the 'updateGameProgression' flag to true. | |||
<pre> | |||
2 => array( | |||
"name" => "playerTurn", | |||
"description" => clienttranslate('${actplayer} must play a stone'), | |||
"descriptionmyturn" => clienttranslate('${you} must play a stone'), | |||
"type" => "activeplayer", | |||
"possibleactions" => array( "playStone" ), | |||
"transitions" => array( "stonePlayed" => 3, "zombiePass" => 3 ) | |||
), | |||
The basic game turn is implemented: you can drop some stones! | 3 => array( | ||
"name" => "checkEndOfGame", | |||
"description" => '', | |||
"type" => "game", | |||
"action" => "stCheckEndOfGame", | |||
"updateGameProgression" => true, | |||
"transitions" => array( "gameEnded" => 99, "notEndedYet" => 4 ) | |||
), | |||
4 => array( | |||
"name" => "nextPlayer", | |||
"description" => '', | |||
"type" => "game", | |||
"action" => "stNextPlayer", | |||
"transitions" => array( "" => 2 ) | |||
), | |||
</pre> | |||
Implement the 'stNextPlayer()' function in .game.php to manage turn rotation. Except if there are special rules for the game turn depending on context, this is really easy: | |||
<pre> | |||
function stNextPlayer() | |||
{ | |||
self::trace( "stNextPlayer" ); | |||
// Go to next player | |||
$active_player = self::activeNextPlayer(); | |||
self::giveExtraTime( $active_player ); | |||
$this->gamestate->nextState(); | |||
} | |||
</pre> | |||
Add onclick events on intersections in .js->setup() | |||
// Add events on active elements (the third parameter is the method that will be called when the event defined by the second parameter happens - this method must be declared beforehand) | |||
this.addEventToClass( "gmk_intersection", "onclick", "onClickIntersection"); | |||
Declare the corresponding .js->onClickIntersection() function, which calls an action function on the server with appropriate parameters | |||
<pre> | |||
onClickIntersection: function( evt ) | |||
{ | |||
console.log( '$$$$ Event : onClickIntersection' ); | |||
dojo.stopEvent( evt ); | |||
if( ! this.checkAction( 'playStone' ) ) | |||
{ return; } | |||
var node = evt.currentTarget.id; | |||
var coord_x = node.split('_')[1]; | |||
var coord_y = node.split('_')[2]; | |||
console.log( '$$$$ Selected intersection : (' + coord_x + ', ' + coord_y + ')' ); | |||
if ( this.isCurrentPlayerActive() ) { | |||
this.ajaxcall( "/gomoku/gomoku/playStone.html", { lock: true, coord_x: coord_x, coord_y: coord_y }, this, function( result ) {}, function( is_error ) {} ); | |||
} | |||
}, | |||
</pre> | |||
Add this action function in .action.php, retrieving parameters and calling the appropriate game action | |||
<pre> | |||
public function playStone() | |||
{ | |||
self::setAjaxMode(); | |||
// Retrieve arguments | |||
// Note: these arguments correspond to what has been sent through the javascript "ajaxcall" method | |||
$coord_x = self::getArg( "coord_x", AT_posint, true ); | |||
$coord_y = self::getArg( "coord_y", AT_posint, true ); | |||
// Then, call the appropriate method in your game logic, like "playCard" or "myAction" | |||
$this->game->playStone( $coord_x, $coord_y ); | |||
self::ajaxResponse( ); | |||
} | |||
</pre> | |||
Add game action in .game.php to update the database, send a notification to the client providing the event notified (‘stonePlayed’) and its parameters, and proceed to the next state. | |||
<pre> | |||
function playStone( $coord_x, $coord_y ) | |||
{ | |||
// Check that this is player's turn and that it is a "possible action" at this game state (see states.inc.php) | |||
self::checkAction( 'playStone' ); | |||
$player_id = self::getActivePlayerId(); | |||
// Check that this intersection is free | |||
$sql = "SELECT | |||
id, coord_x, coord_y, stone_color | |||
FROM | |||
intersection | |||
WHERE | |||
coord_x = $coord_x | |||
AND coord_y = $coord_y | |||
AND stone_color is null | |||
"; | |||
$intersection = self::getObjectFromDb( $sql ); | |||
if ($intersection == null) { | |||
throw new BgaUserException( self::_("There is already a stone on this intersection, you can't play there") ); | |||
} | |||
// Get player color | |||
$sql = "SELECT | |||
player_id, player_color | |||
FROM | |||
player | |||
WHERE | |||
player_id = $player_id | |||
"; | |||
$player = self::getNonEmptyObjectFromDb( $sql ); | |||
$color = ($player['player_color'] == 'ffffff' ? 'white' : 'black'); | |||
// Update the intersection with a stone of the appropriate color | |||
$intersection_id = $intersection['id']; | |||
$sql = "UPDATE | |||
intersection | |||
SET | |||
stone_color = '$color' | |||
WHERE | |||
id = $intersection_id | |||
"; | |||
self::DbQuery($sql); | |||
// Notify all players | |||
self::notifyAllPlayers( "stonePlayed", clienttranslate( '${player_name} dropped a stone on ${coord_x},${coord_y}' ), array( | |||
'player_id' => $player_id, | |||
'player_name' => self::getActivePlayerName(), | |||
'coord_x' => $coord_x, | |||
'coord_y' => $coord_y, | |||
'color' => $color | |||
) ); | |||
// Go to next game state | |||
$this->gamestate->nextState( "stonePlayed" ); | |||
} | |||
</pre> | |||
Catch the notification in .js->setupNotifications() and link it to a javascript function to execute when the notification is received. | |||
<pre> | |||
setupNotifications: function() | |||
{ | |||
console.log( 'notifications subscriptions setup' ); | |||
dojo.subscribe( 'stonePlayed', this, "notif_stonePlayed" ); | |||
} | |||
</pre> | |||
Implement this function in javascript to update the intersection to show the stone, and register it inside the setNotifications function. | |||
<pre> | |||
notif_stonePlayed: function( notif ) | |||
{ | |||
console.log( '**** Notification : stonePlayed' ); | |||
console.log( notif ); | |||
// Create a stone | |||
dojo.place( this.format_block('jstpl_stone', { | |||
stone_type:'stone_' + notif.args.color, | |||
x:notif.args.coord_x, | |||
y:notif.args.coord_y | |||
} ), $( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y ) ); | |||
// Place it on the player panel | |||
this.placeOnObject( $( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y ), $( 'player_board_' + notif.args.player_id ) ); | |||
// Animate a slide from the player panel to the intersection | |||
dojo.style( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y, 'zIndex', 1 ); | |||
var slide = this.slideToObject( $( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y ), $( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y ), 1000 ); | |||
dojo.connect( slide, 'onEnd', this, dojo.hitch( this, function() { | |||
// At the end of the slide, update the intersection | |||
dojo.removeClass( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y, 'no_stone' ); | |||
dojo.addClass( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y, 'stone_' + notif.args.color ); | |||
dojo.removeClass( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y, 'clickable' ); | |||
// We can now destroy the stone since it is now visible through the change in style of the intersection | |||
dojo.destroy( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y ); | |||
})); | |||
slide.play(); | |||
}, | |||
</pre> | |||
For this function to work properly, you also need: | |||
* to declare a stone javascript template in your .tpl file. | |||
<pre> | |||
var jstpl_stone='<div class="gmk_stone ${stone_type}" id="stone_${x}_${y}"></div>'; | |||
</pre> | |||
* to define the css styles for the stones | |||
<pre> | |||
.gmk_intersection { | |||
width: 30px; | |||
height: 30px; | |||
position: relative; | |||
background-image: url( 'img/stones.png' ); | |||
} | |||
.gmk_stone { | |||
width: 30px; | |||
height: 30px; | |||
position: absolute; | |||
background-image: url( 'img/stones.png' ); | |||
} | |||
.no_stone { background-position: -60px 0px; } | |||
.stone_black { background-position: 0px 0px; } | |||
.stone_white { background-position: -30px 0px; } | |||
</pre> | |||
These styles rely on an PNG image (with transparent background) of both the white and black stones, and positions the background appropriately to show only the part of the background image matching the appropriate stone (or the transparent space if there is no stone). Here is what the image looks like: | |||
[[File:Gomoku stones.png]] | |||
The red circle is used to highlight intersections where you can drop a stone when the player's cursor hovers over them (we also change the cursor to a hand). To do this: | |||
* we define in the css file the 'clickable' css class | |||
<pre> | |||
.clickable { | |||
cursor: pointer; | |||
} | |||
.clickable:hover { background-position: -90px 0px; } | |||
</pre> | |||
* in .js, when we enter the 'playerTurn' state, we add the 'clickable' style to the intersections where there is no stone | |||
<pre> | |||
onEnteringState: function( stateName, args ) | |||
{ | |||
console.log( 'Entering state: '+stateName ); | |||
switch( stateName ) | |||
{ | |||
case 'playerTurn': | |||
if( this.isCurrentPlayerActive() ) | |||
{ | |||
var queueEntries = dojo.query( '.no_stone' ); | |||
for(var i=0; i<queueEntries.length; i++) { | |||
dojo.addClass( queueEntries[i], 'clickable' ); | |||
} | |||
} | |||
} | |||
}, | |||
</pre> | |||
Finally, make sure to modify the default colors for players to white and black | |||
$default_colors = array( "000000", "ffffff", ); | |||
The basic game turn is implemented: you can now drop some stones! | |||
[[File:Gomoku tuto4.png]] | [[File:Gomoku tuto4.png]] | ||
== Cleanup == | == Cleanup your styles == | ||
Remove temporary css visualisation helpers : looks good! | Remove temporary css visualisation helpers : looks good! | ||
Радок 52: | Радок 474: | ||
[[File:Gomoku tuto5.png]] | [[File:Gomoku tuto5.png]] | ||
== Implement rules and end of game | == Add some counters in the player panels == | ||
Edit .tpl to create a template for your counters. | |||
<pre> | |||
var jstpl_player_board = '\<div class="cp_board">\ | |||
<div id="stoneicon_p${id}" class="gmk_stoneicon gmk_stoneicon_${color}"></div><span id="stonecount_p${id}">0</span>\ | |||
</div>'; | |||
</pre> | |||
Edit .js->setup() to setup the player panels with this extra information | |||
<pre> | |||
// Setting up player boards | |||
for( var player_id in gamedatas.players ) | |||
{ | |||
var player = gamedatas.players[player_id]; | |||
// Setting up players boards if needed | |||
var player_board_div = $('player_board_'+player_id); | |||
dojo.place( this.format_block('jstpl_player_board', player ), player_board_div ); | |||
} | |||
</pre> | |||
Add some styles in your .css | |||
<pre> | |||
.gmk_stoneicon { | |||
width: 14px; | |||
height: 14px; | |||
display: inline-block; | |||
position: relative; | |||
background-repeat: no-repeat; | |||
background-image: url( 'img/stone_icons.png'); | |||
background-position: -28px 0px; | |||
margin-top: 4px; | |||
margin-right: 3px; | |||
} | |||
.gmk_stoneicon_000000 { | |||
background-position:0px 0px | |||
} | |||
.gmk_stoneicon_ffffff { | |||
background-position:-14px 0px | |||
} | |||
.cp_board { | |||
clear: both; | |||
} | |||
</pre> | |||
In your .game.php, create a function to return your game counters | |||
<pre> | |||
/* | |||
getGameCounters: | |||
Gather all relevant counters about current game situation (visible by the current player). | |||
*/ | |||
function getGameCounters($player_id) { | |||
$sql = " | |||
SELECT | |||
concat('stonecount_p', cast(p.player_id as char)) counter_name, | |||
case when p.player_color = 'white' then 180 - count(id) else 181 - count(id) end counter_value | |||
FROM (select player_id, case when player_color = 'ffffff' then 'white' else 'black' end player_color FROM player) p | |||
LEFT JOIN intersection i on i.stone_color = p.player_color | |||
GROUP BY p.player_color, p.player_id | |||
"; | |||
if ($player_id != null) { | |||
// Player private counters: concatenate extra SQL request with UNION using the $player_id parameter | |||
} | |||
return self::getNonEmptyCollectionFromDB( $sql ); | |||
} | |||
</pre> | |||
Return your game counters in your .game.php->getAllDatas() | |||
<pre> | |||
// Counters | |||
$result['counters'] = $this->getGameCounters($current_player_id); | |||
</pre> | |||
And pass them in any notification that needs to update them | |||
<pre> | |||
// Notify all players | |||
self::notifyAllPlayers( "stonePlayed", clienttranslate( '${player_name} dropped a stone ${coordinates}' ), array( | |||
'player_id' => $player_id, | |||
'player_name' => self::getActivePlayerName(), | |||
'coordinates' => $this->getFormattedCoordinates($coord_x, $coord_y), | |||
'coord_x' => $coord_x, | |||
'coord_y' => $coord_y, | |||
'color' => $color, | |||
'counters' => $this->getGameCounters(self::getCurrentPlayerId()) | |||
) ); | |||
</pre> | |||
Finally, in your .js->setup() and notification handler function call respectively | |||
<pre> | |||
this.updateCounters(gamedatas.counters); | |||
</pre> | |||
and | |||
<pre> | |||
this.updateCounters(notif.args.counters); | |||
</pre> | |||
You now have a working counter! | |||
[[File:Gomoku tuto7.png]] | |||
== Implement rules and end of game conditions == | |||
Implement specific rules for the game. For example in Gomoku, black plays first. So in .game.php->setupNewGame(), at the end of the setup make the black player active: | |||
<pre> | |||
// Black plays first | |||
$sql = "SELECT player_id, player_name FROM player WHERE player_color = '000000' "; | |||
$black_player = self::getNonEmptyObjectFromDb( $sql ); | |||
$this->gamestate->changeActivePlayer( $black_player['player_id'] ); | |||
</pre> | |||
Implement rule for computing game progression in .game.php->getGameProgression(). For Gomoku we will use the rate of occupied intersections over the total number of intersections. This will often be wildly inaccurate as the game can end pretty quickly, but it's about the best we can do (the game can drag to a stalemate with all intersections occupied and no winner). | |||
<pre> | |||
function getGameProgression() | |||
{ | |||
// Compute and return the game progression | |||
// Number of stones laid down on the goban over the total number of intersections * 100 | |||
$sql = " | |||
SELECT round(100 * count(id) / (19*19) ) as value from intersection WHERE stone_color is not null | |||
"; | |||
$counter = self::getNonEmptyObjectFromDB( $sql ); | |||
return $counter['value']; | |||
} | |||
</pre> | |||
Implement end of game detection and update the score according to who is the winner. It is easier to check for a win directly after setting the stone, so: | |||
* declare a global 'end_of_game' variable in .game.php->Gomoku() | |||
self::initGameStateLabels( array( | |||
"end_of_game" => 10, | |||
) ); | |||
* init that global variable to 0 in .game.php->setupNewGame() | |||
self::setGameStateInitialValue( 'end_of_game', 0 ); | |||
* add the appropriate code in .game.php before proceeding to the next state, using a checkForWin() function implemented separately for clarity. If the game has been won, we set the score, send a score update notification to the client side, and set the 'end_of_game' global variable to 1 as a flag signaling that the game has ended. | |||
<pre> | |||
// Check if end of game has been met | |||
if ($this->checkForWin( $coord_x, $coord_y, $color )) { | |||
// Set active player score to 1 (he is the winner) | |||
$sql = "UPDATE player SET player_score = 1 WHERE player_id = $player_id"; | |||
self::DbQuery($sql); | |||
// Notify final score | |||
$this->notifyAllPlayers( "finalScore", | |||
clienttranslate( '${player_name} wins the game!' ), | |||
array( | |||
"player_name" => self::getActivePlayerName(), | |||
"player_id" => $player_id, | |||
"score_delta" => 1, | |||
) | |||
); | |||
// Set global variable flag to pass on the information that the game has ended | |||
self::setGameStateValue('end_of_game', 1); | |||
// End of game message | |||
$this->notifyAllPlayers( "message", | |||
clienttranslate('Thanks for playing!'), | |||
array( | |||
) | |||
); | |||
} | |||
</pre> | |||
* Then in the gomoku->stCheckEndOfGame() function which is called when your state machine goes to the 'checkEndOfGame' state, check for this variable and for other possible 'end of game' conditions (draw). | |||
<pre> | |||
function stCheckEndOfGame() | |||
{ | |||
self::trace( "stCheckEndOfGame" ); | |||
$transition = "notEndedYet"; | |||
// If there is no more free intersections, the game ends | |||
$sql = "SELECT id, coord_x, coord_y, stone_color FROM intersection WHERE stone_color is null"; | |||
$free = self::getCollectionFromDb( $sql ); | |||
if (count($free) == 0) { | |||
$transition = "gameEnded"; | |||
} | |||
// If the 'end of game' flag has been set, end the game | |||
if (self::getGameStateValue('end_of_game') == 1) { | |||
$transition = "gameEnded"; | |||
} | |||
$this->gamestate->nextState( $transition ); | |||
} | |||
</pre> | |||
* Catch the score notification on the client side in .js->setupNotifications(). It is advised to set up a small delay after that so that end of game popup doesn't show too quickly. | |||
<pre> | |||
dojo.subscribe( 'finalScore', this, "notif_finalScore" ); | |||
this.notifqueue.setSynchronous( 'finalScore', 1500 ); | |||
</pre> | |||
Implement | * Implement the function declared to handle the notification. | ||
<pre> | |||
notif_finalScore: function( notif ) | |||
{ | |||
console.log( '**** Notification : finalScore' ); | |||
console.log( notif ); | |||
// Update score | |||
this.scoreCtrl[ notif.args.player_id ].incValue( notif.args.score_delta ); | |||
}, | |||
</pre> | |||
'''Test everything thoroughly... you are done!''' | '''Test everything thoroughly... you are done!''' | ||
[[File:Gomoku tuto6.png]] | [[File:Gomoku tuto6.png]] |
Актуальная версія на 22:14, 15 красавіка 2020
This tutorial will guide you through the basics of creating a simple game on BGA Studio, through the example of Gomoku (also known as Gobang or Five in a Row).
You will start from our 'empty game' template
Here is how your games looks by default when it has just been created:
Setup the board
Gather useful images for the game and edit them as needed. Upload them in the 'img' folder of your SFTP access.
Edit .tpl to add some divs for the board in the HTML. For example:
<div id="gmk_game_area"> <div id="gmk_background"> <div id="gmk_goban"> </div> </div> </div>
Edit .css to set the div sizes and positions and show the image of the board as background.
#gmk_game_area { text-align: center; position: relative; } #gmk_background { width: 620px; height: 620px; position: relative; display: inline-block; } #gmk_goban { background-image: url( 'img/goban.jpg'); width: 620px; height: 620px; position: absolute; }
Setup the backbone of your game
Edit dbmodel.sql to create a table for intersections. We need coordinates for each intersection and a field to store the color of the stone on this intersection (if any).
CREATE TABLE IF NOT EXISTS `intersection` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `coord_x` tinyint(2) unsigned NOT NULL, `coord_y` tinyint(2) unsigned NOT NULL, `stone_color` varchar(8) NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;
Edit .game.php->setupNewGame() to insert the empty intersections (19x19) with coordinates into the database.
// Insert (empty) intersections into database $sql = "INSERT INTO intersection (coord_x, coord_y) VALUES "; $values = array(); for ($x = 0; $x < 19; $x++) { for ($y = 0; $y < 19; $y++) { $values[] = "($x, $y)"; } } $sql .= implode( $values, ',' ); self::DbQuery( $sql );
Edit .game.php->getAllDatas() to retrieve the state of the intersections from the database.
// Intersections $sql = "SELECT id, coord_x, coord_y, stone_color FROM intersection "; $result['intersections'] = self::getCollectionFromDb( $sql );
Edit .tpl to create a template for intersections.
var jstpl_intersection='<div class="gmk_intersection ${stone_type}" id="intersection_${x}_${y}"></div>';
Define the styles for the intersection divs.
.gmk_intersection { width: 30px; height: 30px; position: relative; }
Edit .js->setup() to setup the intersections layer that will be used to get click events and to display the stones. The data you returned in $result['intersections'] in .game.php->getAllDatas() is now available in your .js->setup() in gamedatas.intersections.
// Setup intersections for( var id in gamedatas.intersections ) { var intersection = gamedatas.intersections[id]; dojo.place( this.format_block('jstpl_intersection', { x:intersection.coord_x, y:intersection.coord_y, stone_type:(intersection.stone_color == null ? "no_stone" : 'stone_' + intersection.stone_color) } ), $ ( 'gmk_background' ) ); var x_pix = this.getXPixelCoordinates(intersection.coord_x); var y_pix = this.getYPixelCoordinates(intersection.coord_y); this.slideToObjectPos( $('intersection_'+intersection.coord_x+'_'+intersection.coord_y), $('gmk_background'), x_pix, y_pix, 10 ).play(); if (intersection.stone_color != null) { // This intersection is taken, it shouldn't appear as clickable anymore dojo.removeClass( 'intersection_' + intersection.coord_x + '_' + intersection.coord_y, 'clickable' ); } }
Use some temporary css border-color or background-color and opacity to see the divs and make sure you have them positioned right.
.gmk_intersection { width: 30px; height: 30px; position: relative; background-color: blue; opacity: 0.3; }
You can declare some constants in material.inc.php and pass them to your .js for easy repositioning (modify constant, refresh). This is especially useful if the same constants have to be used on the server and on the client.
- Declare your constants in material.inc.php (this will be automatically included in your .game.php)
$this->gameConstants = array( "INTERSECTION_WIDTH" => 30, "INTERSECTION_HEIGHT" => 30, "INTERSECTION_X_SPACER" => 2.8, // Float "INTERSECTION_Y_SPACER" => 2.8, // Float "X_ORIGIN" => 0, "Y_ORIGIN" => 0, );
- In .game.php->getAllDatas(), add the constants to the result array
// Constants $result['constants'] = $this->gameConstants;
- In .js constructor, define a class variable for constants
// Game constants this.gameConstants = null;
- In .js->setup() assign the constants to this variable
this.gameConstants = gamedatas.constants;
- Then use it in your getXPixelCoordinates and getYPixelCoordinates functions
getXPixelCoordinates: function( intersection_x ) { return this.gameConstants['X_ORIGIN'] + intersection_x * (this.gameConstants['INTERSECTION_WIDTH'] + this.gameConstants['INTERSECTION_X_SPACER']); }, getYPixelCoordinates: function( intersection_y ) { return this.gameConstants['Y_ORIGIN'] + intersection_y * (this.gameConstants['INTERSECTION_HEIGHT'] + this.gameConstants['INTERSECTION_Y_SPACER']); },
Here is what you should get:
Manage states and events
Define your game states in states.inc.php. For gomoku we will use 3 states in addition of the predefined states 1 (gameSetup) and 99 (gameEnd). One to play, one to check the end game condition, one to give his turn to the other player if the game is not over.
The first state requires an action from the player, so its type is 'activeplayer'.
The two others are automatic actions for the game, so their type is 'game'.
We will update the progression while checking for the end of the game, so for this state we set the 'updateGameProgression' flag to true.
2 => array( "name" => "playerTurn", "description" => clienttranslate('${actplayer} must play a stone'), "descriptionmyturn" => clienttranslate('${you} must play a stone'), "type" => "activeplayer", "possibleactions" => array( "playStone" ), "transitions" => array( "stonePlayed" => 3, "zombiePass" => 3 ) ), 3 => array( "name" => "checkEndOfGame", "description" => '', "type" => "game", "action" => "stCheckEndOfGame", "updateGameProgression" => true, "transitions" => array( "gameEnded" => 99, "notEndedYet" => 4 ) ), 4 => array( "name" => "nextPlayer", "description" => '', "type" => "game", "action" => "stNextPlayer", "transitions" => array( "" => 2 ) ),
Implement the 'stNextPlayer()' function in .game.php to manage turn rotation. Except if there are special rules for the game turn depending on context, this is really easy:
function stNextPlayer() { self::trace( "stNextPlayer" ); // Go to next player $active_player = self::activeNextPlayer(); self::giveExtraTime( $active_player ); $this->gamestate->nextState(); }
Add onclick events on intersections in .js->setup()
// Add events on active elements (the third parameter is the method that will be called when the event defined by the second parameter happens - this method must be declared beforehand) this.addEventToClass( "gmk_intersection", "onclick", "onClickIntersection");
Declare the corresponding .js->onClickIntersection() function, which calls an action function on the server with appropriate parameters
onClickIntersection: function( evt ) { console.log( '$$$$ Event : onClickIntersection' ); dojo.stopEvent( evt ); if( ! this.checkAction( 'playStone' ) ) { return; } var node = evt.currentTarget.id; var coord_x = node.split('_')[1]; var coord_y = node.split('_')[2]; console.log( '$$$$ Selected intersection : (' + coord_x + ', ' + coord_y + ')' ); if ( this.isCurrentPlayerActive() ) { this.ajaxcall( "/gomoku/gomoku/playStone.html", { lock: true, coord_x: coord_x, coord_y: coord_y }, this, function( result ) {}, function( is_error ) {} ); } },
Add this action function in .action.php, retrieving parameters and calling the appropriate game action
public function playStone() { self::setAjaxMode(); // Retrieve arguments // Note: these arguments correspond to what has been sent through the javascript "ajaxcall" method $coord_x = self::getArg( "coord_x", AT_posint, true ); $coord_y = self::getArg( "coord_y", AT_posint, true ); // Then, call the appropriate method in your game logic, like "playCard" or "myAction" $this->game->playStone( $coord_x, $coord_y ); self::ajaxResponse( ); }
Add game action in .game.php to update the database, send a notification to the client providing the event notified (‘stonePlayed’) and its parameters, and proceed to the next state.
function playStone( $coord_x, $coord_y ) { // Check that this is player's turn and that it is a "possible action" at this game state (see states.inc.php) self::checkAction( 'playStone' ); $player_id = self::getActivePlayerId(); // Check that this intersection is free $sql = "SELECT id, coord_x, coord_y, stone_color FROM intersection WHERE coord_x = $coord_x AND coord_y = $coord_y AND stone_color is null "; $intersection = self::getObjectFromDb( $sql ); if ($intersection == null) { throw new BgaUserException( self::_("There is already a stone on this intersection, you can't play there") ); } // Get player color $sql = "SELECT player_id, player_color FROM player WHERE player_id = $player_id "; $player = self::getNonEmptyObjectFromDb( $sql ); $color = ($player['player_color'] == 'ffffff' ? 'white' : 'black'); // Update the intersection with a stone of the appropriate color $intersection_id = $intersection['id']; $sql = "UPDATE intersection SET stone_color = '$color' WHERE id = $intersection_id "; self::DbQuery($sql); // Notify all players self::notifyAllPlayers( "stonePlayed", clienttranslate( '${player_name} dropped a stone on ${coord_x},${coord_y}' ), array( 'player_id' => $player_id, 'player_name' => self::getActivePlayerName(), 'coord_x' => $coord_x, 'coord_y' => $coord_y, 'color' => $color ) ); // Go to next game state $this->gamestate->nextState( "stonePlayed" ); }
Catch the notification in .js->setupNotifications() and link it to a javascript function to execute when the notification is received.
setupNotifications: function() { console.log( 'notifications subscriptions setup' ); dojo.subscribe( 'stonePlayed', this, "notif_stonePlayed" ); }
Implement this function in javascript to update the intersection to show the stone, and register it inside the setNotifications function.
notif_stonePlayed: function( notif ) { console.log( '**** Notification : stonePlayed' ); console.log( notif ); // Create a stone dojo.place( this.format_block('jstpl_stone', { stone_type:'stone_' + notif.args.color, x:notif.args.coord_x, y:notif.args.coord_y } ), $( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y ) ); // Place it on the player panel this.placeOnObject( $( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y ), $( 'player_board_' + notif.args.player_id ) ); // Animate a slide from the player panel to the intersection dojo.style( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y, 'zIndex', 1 ); var slide = this.slideToObject( $( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y ), $( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y ), 1000 ); dojo.connect( slide, 'onEnd', this, dojo.hitch( this, function() { // At the end of the slide, update the intersection dojo.removeClass( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y, 'no_stone' ); dojo.addClass( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y, 'stone_' + notif.args.color ); dojo.removeClass( 'intersection_' + notif.args.coord_x + '_' + notif.args.coord_y, 'clickable' ); // We can now destroy the stone since it is now visible through the change in style of the intersection dojo.destroy( 'stone_' + notif.args.coord_x + '_' + notif.args.coord_y ); })); slide.play(); },
For this function to work properly, you also need:
- to declare a stone javascript template in your .tpl file.
var jstpl_stone='<div class="gmk_stone ${stone_type}" id="stone_${x}_${y}"></div>';
- to define the css styles for the stones
.gmk_intersection { width: 30px; height: 30px; position: relative; background-image: url( 'img/stones.png' ); } .gmk_stone { width: 30px; height: 30px; position: absolute; background-image: url( 'img/stones.png' ); } .no_stone { background-position: -60px 0px; } .stone_black { background-position: 0px 0px; } .stone_white { background-position: -30px 0px; }
These styles rely on an PNG image (with transparent background) of both the white and black stones, and positions the background appropriately to show only the part of the background image matching the appropriate stone (or the transparent space if there is no stone). Here is what the image looks like:
The red circle is used to highlight intersections where you can drop a stone when the player's cursor hovers over them (we also change the cursor to a hand). To do this:
- we define in the css file the 'clickable' css class
.clickable { cursor: pointer; } .clickable:hover { background-position: -90px 0px; }
- in .js, when we enter the 'playerTurn' state, we add the 'clickable' style to the intersections where there is no stone
onEnteringState: function( stateName, args ) { console.log( 'Entering state: '+stateName ); switch( stateName ) { case 'playerTurn': if( this.isCurrentPlayerActive() ) { var queueEntries = dojo.query( '.no_stone' ); for(var i=0; i<queueEntries.length; i++) { dojo.addClass( queueEntries[i], 'clickable' ); } } } },
Finally, make sure to modify the default colors for players to white and black
$default_colors = array( "000000", "ffffff", );
The basic game turn is implemented: you can now drop some stones!
Cleanup your styles
Remove temporary css visualisation helpers : looks good!
Add some counters in the player panels
Edit .tpl to create a template for your counters.
var jstpl_player_board = '\<div class="cp_board">\ <div id="stoneicon_p${id}" class="gmk_stoneicon gmk_stoneicon_${color}"></div><span id="stonecount_p${id}">0</span>\ </div>';
Edit .js->setup() to setup the player panels with this extra information
// Setting up player boards for( var player_id in gamedatas.players ) { var player = gamedatas.players[player_id]; // Setting up players boards if needed var player_board_div = $('player_board_'+player_id); dojo.place( this.format_block('jstpl_player_board', player ), player_board_div ); }
Add some styles in your .css
.gmk_stoneicon { width: 14px; height: 14px; display: inline-block; position: relative; background-repeat: no-repeat; background-image: url( 'img/stone_icons.png'); background-position: -28px 0px; margin-top: 4px; margin-right: 3px; } .gmk_stoneicon_000000 { background-position:0px 0px } .gmk_stoneicon_ffffff { background-position:-14px 0px } .cp_board { clear: both; }
In your .game.php, create a function to return your game counters
/* getGameCounters: Gather all relevant counters about current game situation (visible by the current player). */ function getGameCounters($player_id) { $sql = " SELECT concat('stonecount_p', cast(p.player_id as char)) counter_name, case when p.player_color = 'white' then 180 - count(id) else 181 - count(id) end counter_value FROM (select player_id, case when player_color = 'ffffff' then 'white' else 'black' end player_color FROM player) p LEFT JOIN intersection i on i.stone_color = p.player_color GROUP BY p.player_color, p.player_id "; if ($player_id != null) { // Player private counters: concatenate extra SQL request with UNION using the $player_id parameter } return self::getNonEmptyCollectionFromDB( $sql ); }
Return your game counters in your .game.php->getAllDatas()
// Counters $result['counters'] = $this->getGameCounters($current_player_id);
And pass them in any notification that needs to update them
// Notify all players self::notifyAllPlayers( "stonePlayed", clienttranslate( '${player_name} dropped a stone ${coordinates}' ), array( 'player_id' => $player_id, 'player_name' => self::getActivePlayerName(), 'coordinates' => $this->getFormattedCoordinates($coord_x, $coord_y), 'coord_x' => $coord_x, 'coord_y' => $coord_y, 'color' => $color, 'counters' => $this->getGameCounters(self::getCurrentPlayerId()) ) );
Finally, in your .js->setup() and notification handler function call respectively
this.updateCounters(gamedatas.counters);
and
this.updateCounters(notif.args.counters);
You now have a working counter!
Implement rules and end of game conditions
Implement specific rules for the game. For example in Gomoku, black plays first. So in .game.php->setupNewGame(), at the end of the setup make the black player active:
// Black plays first $sql = "SELECT player_id, player_name FROM player WHERE player_color = '000000' "; $black_player = self::getNonEmptyObjectFromDb( $sql ); $this->gamestate->changeActivePlayer( $black_player['player_id'] );
Implement rule for computing game progression in .game.php->getGameProgression(). For Gomoku we will use the rate of occupied intersections over the total number of intersections. This will often be wildly inaccurate as the game can end pretty quickly, but it's about the best we can do (the game can drag to a stalemate with all intersections occupied and no winner).
function getGameProgression() { // Compute and return the game progression // Number of stones laid down on the goban over the total number of intersections * 100 $sql = " SELECT round(100 * count(id) / (19*19) ) as value from intersection WHERE stone_color is not null "; $counter = self::getNonEmptyObjectFromDB( $sql ); return $counter['value']; }
Implement end of game detection and update the score according to who is the winner. It is easier to check for a win directly after setting the stone, so:
- declare a global 'end_of_game' variable in .game.php->Gomoku()
self::initGameStateLabels( array( "end_of_game" => 10, ) );
- init that global variable to 0 in .game.php->setupNewGame()
self::setGameStateInitialValue( 'end_of_game', 0 );
- add the appropriate code in .game.php before proceeding to the next state, using a checkForWin() function implemented separately for clarity. If the game has been won, we set the score, send a score update notification to the client side, and set the 'end_of_game' global variable to 1 as a flag signaling that the game has ended.
// Check if end of game has been met if ($this->checkForWin( $coord_x, $coord_y, $color )) { // Set active player score to 1 (he is the winner) $sql = "UPDATE player SET player_score = 1 WHERE player_id = $player_id"; self::DbQuery($sql); // Notify final score $this->notifyAllPlayers( "finalScore", clienttranslate( '${player_name} wins the game!' ), array( "player_name" => self::getActivePlayerName(), "player_id" => $player_id, "score_delta" => 1, ) ); // Set global variable flag to pass on the information that the game has ended self::setGameStateValue('end_of_game', 1); // End of game message $this->notifyAllPlayers( "message", clienttranslate('Thanks for playing!'), array( ) ); }
- Then in the gomoku->stCheckEndOfGame() function which is called when your state machine goes to the 'checkEndOfGame' state, check for this variable and for other possible 'end of game' conditions (draw).
function stCheckEndOfGame() { self::trace( "stCheckEndOfGame" ); $transition = "notEndedYet"; // If there is no more free intersections, the game ends $sql = "SELECT id, coord_x, coord_y, stone_color FROM intersection WHERE stone_color is null"; $free = self::getCollectionFromDb( $sql ); if (count($free) == 0) { $transition = "gameEnded"; } // If the 'end of game' flag has been set, end the game if (self::getGameStateValue('end_of_game') == 1) { $transition = "gameEnded"; } $this->gamestate->nextState( $transition ); }
- Catch the score notification on the client side in .js->setupNotifications(). It is advised to set up a small delay after that so that end of game popup doesn't show too quickly.
dojo.subscribe( 'finalScore', this, "notif_finalScore" ); this.notifqueue.setSynchronous( 'finalScore', 1500 );
- Implement the function declared to handle the notification.
notif_finalScore: function( notif ) { console.log( '**** Notification : finalScore' ); console.log( notif ); // Update score this.scoreCtrl[ notif.args.player_id ].incValue( notif.args.score_delta ); },
Test everything thoroughly... you are done!