This is a documentation for Board Game Arena: play board games online !

Tutorial gomoku

De Board Game Arena
Ir a la navegación Ir a la búsqueda

Este tutorial te guiará a través de los fundamentos de la creación de un juego sencillo en BGA Studio, a través del ejemplo de Gomoku (también conocido como Gobang o Five in a Row).

Empezarás con nuestra plantilla de "juego vacío"

Este es el aspecto por defecto de tus juegos cuando acaban de ser creados:

Gomoku tuto1.png

Prepara el tablero

Reúne las imágenes útiles para el juego y edítalas como sea necesario. Súbelas en la carpeta 'img' de tu acceso SFTP.

Edita el .tpl para añadir algunos divs para el tablero en el HTML. Por ejemplo:

<div id="gmk_game_area">
	<div id="gmk_background">
		<div id="gmk_goban">
		</div>
	</div>	
</div>

Editar el .css para establecer los tamaños y posiciones de los div y mostrar la imagen del tablero como fondo.

#gmk_game_area {
	text-align: center;
	position: relative;
}

#gmk_background {
	ancho: 620px;
	altura: 620px;	
	position: relative;
	display: inline-block;
}

#gmk_goban {	
	background-image: url( 'img/goban.jpg');
	anchura: 620px;
	altura: 620px;
	position: absolute;	
}

Gomoku tuto2.png

Configura la columna vertebral de tu juego

Edita dbmodel.sql para crear una tabla para las intersecciones. Necesitamos coordenadas para cada intersección y un campo para almacenar el color de la piedra en esta intersección (si la hay).

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,
   `color_piedra` varchar(8) NULL,
   PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;

Edita .game.php->setupNewGame() para insertar las intersecciones vacías (19x19) con coordenadas en la base de datos.

        // Insertar las intersecciones (vacías) en la base de datos
        $sql = "INSERT INTO intersection (coord_x, coord_y) VALUES ";
        $valores = array();
        for ($x = 0; $x < 19; $x++) {
            for ($y = 0; $y < 19; $y++) {
        	
            	$valores[] = "($x, $y)";   	
            }
        }
        $sql .= implode( $valores, ',' );
        self::DbQuery( $sql );

Edita .game.php->getAllDatas() para recuperar el estado de las intersecciones de la base de datos.

        // Intersecciones
        $sql = "SELECT id, coord_x, coord_y, stone_color FROM intersection ";
        $resultado['intersecciones'] = self::getCollectionFromDb( $sql );

Edita el .tpl para crear una plantilla para las intersecciones.

var jstpl_intersection='<div class="gmk_intersection ${stone_type}" id="intersection_${x}_${y}"></div>';

Definir los estilos para los divs de intersección.

.gmk_intersection {
    width: 30px;
    height: 30px;
    position: relative;
}

Edita .js->setup() para configurar la capa de intersecciones que se utilizará para obtener los eventos de clic y para mostrar las piedras. Los datos que devolviste en $result['intersections'] en .game.php->getAllDatas() están ahora disponibles en tu .js->setup() en gamedatas.intersections.

            // Configurar intersecciones
            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,
                    tipo_piedra:(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) {
                    // Esta intersección está tomada, ya no debería aparecer como clicable
                    dojo.removeClass( 'intersection_' + intersection.coord_x + '_' + intersection.coord_y, 'clickable' );
                }
            } 

Utiliza un poco de css temporal border-color o background-color y opacidad para ver los divs y asegurarte de que los tienes bien posicionados.

.gmk_intersection {
    width: 30px;
    height: 30px;
    posición: relative;
    background-color: blue;
    opacity: 0.3;
}

Puedes declarar algunas constantes en material.inc.php y pasarlas a tu .js para facilitar el reposicionamiento (modificar la constante, refrescar). Esto es especialmente útil si las mismas constantes tienen que ser utilizadas en el servidor y en el cliente.

  • Declare sus constantes en material.inc.php (esto se incluirá automáticamente en su .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,
);
  • En .game.php->getAllDatas(), añada las constantes a la matriz de resultados
       // Constantes
       $resultado['constantes'] = $this->gameConstants;
  • En el constructor .js, definir una variable de clase para las constantes
       // Constantes del juego
     	this.gameConstants = null;
  • En .js->setup() asigna las constantes a esta variable
       this.gameConstants = gamedatas.constants;
  • Luego úsalo en tus funciones getXPixelCoordinates y getYPixelCoordinates
       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['ORIGEN_Y'] + intersection_y * (this.gameConstants['ALTURA_INTERSECCIÓN'] + this.gameConstants['ESPACIADOR_Y']); 
       },

Esto es lo que deberías obtener:

Gomoku tuto3.png

Gestión de estados y eventos

Define los estados de tu juego en states.inc.php. Para gomoku usaremos 3 estados además de los estados predefinidos 1 (gameSetup) y 99 (gameEnd). Uno para jugar, otro para comprobar la condición de fin de juego, otro para ceder su turno al otro jugador si la partida no ha terminado.

El primer estado requiere una acción del jugador, por lo que su tipo es "jugador activo".

Los otros dos son acciones automáticas para el juego, por lo que su tipo es 'juego'.

Actualizaremos la progresión mientras comprobamos el final de la partida, por lo que para este estado ponemos el flag 'updateGameProgression' a true.

    2 => array(
        "name" => "playerTurn",
        "description" => clienttranslate('${actplayer} debe jugar una piedra'),
        "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 )
    ),

Implementa la función 'stNextPlayer()' en .game.php para gestionar la rotación de los turnos. Excepto si hay reglas especiales para el turno de juego dependiendo del contexto, esto es realmente fácil:

    función stNextPlayer()
    {
    	self::trace( "stNextPlayer" );
    	 
    	// Pasar al siguiente jugador
    	$jugador_activo = self::activeNextPlayer();
    	self::giveExtraTime( $jugador_activo );    
    	 
    	$this->gamestate->nextState();
    }

Añadir eventos onclick en las intersecciones en .js->setup()

           // Añadir eventos en elementos activos (el tercer parámetro es el método que se llamará cuando ocurra el evento definido por el segundo parámetro - este método debe ser declarado previamente)
           this.addEventToClass( "gmk_intersection", "onclick", "onClickIntersection");

Declarar la función correspondiente.js->onClickIntersection(), que llama a una función de acción en el servidor con los parámetros adecuados. En la ruta de ajaxcall, sustituye "gomoku" por tu propio nombre de proyecto.

        onClickIntersection: function( evt )
        {
            console.log( '$$$$ Evento : 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( '$$$$ Intersección seleccionada : (' + 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 ) {} );
            }
        },

Añade esta función de acción en .action.php, recuperando los parámetros y llamando a la acción del juego correspondiente

    public function jugarPiedra()
    {
        self::setAjaxMode();     

        // Recuperar los argumentos
        // Nota: estos argumentos corresponden a lo que se ha enviado a través del método "ajaxcall" de javascript
        $coord_x = self::getArg( "coord_x", AT_posint, true );
        $coord_y = self::getArg( "coord_y", AT_posint, true );

        // Luego, llama al método apropiado en tu lógica de juego, como "playCard" o "myAction"
        $this->game->playStone( $coord_x, $coord_y );

        self::ajaxResponse( );
    }

Añadir la acción del juego en .game.php para actualizar la base de datos, enviar una notificación al cliente proporcionando el evento notificado ('stonePlayed') y sus parámetros, y proceder al siguiente estado.

    function playStone( $coord_x, $coord_y )
    {
        // Comprueba que es el turno del jugador y que es una "acción posible" en este estado del juego (ver states.inc.php)
        self::checkAction( 'playStone' ); 
        
        $player_id = self::getActivePlayerId();
        
        // Comprobar que esta intersección está libre
        $sql = "SELECT
                    id, coord_x, coord_y, stone_color
                FROM
                    intersección 
                WHERE 
                    coord_x = $coord_x 
                    AND coord_y = $coord_y
                    AND stone_color is null
               ";
        $intersección = self::getObjectFromDb( $sql );

        if ($intersection == null) {
            throw new BgaUserException( self::_("Ya hay una piedra en esta intersección, no puedes jugar ahí") );
        }

        // Obtener el color del jugador
        $sql = "SELECT
                    player_id, player_color
                FROM
                    jugador 
                WHERE 
                    player_id = $player_id
               ";
        $jugador = self::getNonEmptyObjectFromDb( $sql );
        $color = ($jugador['color_jugador'] == 'ffffff' ? 'blanco' : 'negro');

        // Actualizar la intersección con una piedra del color apropiado
        $intersection_id = $intersection['id'];
        $sql = "UPDATE
                    intersección
                SET
                    color_piedra = '$color'
                WHERE 
                    id = $intersection_id
               ";
        self::DbQuery($sql);
        
        // Notificar a todos los jugadores
        self::notifyAllPlayers( "stonePlayed", clienttranslate( '${player_name} dropped a stone on ${coord_x},${coord_y}' ), array(
            'player_id' => $player_id,
            'nombre_jugador' => self::getActivePlayerName(),
            'coord_x' => $coord_x,
            'coord_y' => $coord_y,
            'color' => $color
        ) );

        // Pasar al siguiente estado del juego
        $this->gamestate->nextState( "stonePlayed" );
    }

Captura la notificación en .js->setupNotifications() y enlázala a una función javascript para que se ejecute cuando se reciba la notificación.

        setupNotifications: function()
        {
            console.log( 'setup notifications subscriptions' );
        
            dojo.subscribe( 'stonePlayed', this, "notif_stonePlayed" );
        }

Implementar esta función en javascript para actualizar la intersección para mostrar la piedra, y registrarla dentro de la función setNotifications.

        notif_stonePlayed: function( notif )
        {
	    console.log( '**** Notificación : stonePlayed' );
            console.log( notif );

            // Crear una piedra
            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 );

            // Colócalo en el panel del reproductor
            this.placeOnObject( $( 'piedra_' + notif.args.coord_x + '_' + notif.args.coord_y ), $( 'tablero_deljugador_' + notif.args.player_id );

            // Animar un deslizamiento desde el panel del reproductor hasta la intersección
            dojo.style( 'piedra_' + 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() {
                        // Al final de la diapositiva, actualiza la intersección 
                        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' );
        			
                        // Ahora podemos destruir la piedra ya que ahora es visible por el cambio de estilo de la intersección
                        dojo.destroy( 'piedra_' + notif.args.coord_x + '_' + notif.args.coord_y );
       	    }));
            slide.play();
        },

Para que esta función funcione correctamente, también es necesario:

  • declarar una plantilla javascript de piedra en su archivo .tpl.
var jstpl_stone='<div class="gmk_stone ${stone_type}" id="stone_${x}_${y}"></div>';
  • para definir los estilos css de las piedras
.gmk_intersection {
    width: 30px;
    height: 30px;
    posición: relativa;
    background-image: url( 'img/stones.png' );
}

.gmk_stone {
    anchura: 30px;
    altura: 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; }

Estos estilos se basan en una imagen PNG (con fondo transparente) tanto de las piedras blancas como de las negras, y posiciona el fondo adecuadamente para mostrar sólo la parte de la imagen de fondo que coincide con la piedra correspondiente (o el espacio transparente si no hay piedra). Este es el aspecto de la imagen:

Gomoku stones.png

El círculo rojo se utiliza para resaltar las intersecciones en las que se puede dejar caer una piedra cuando el cursor del jugador pasa por encima de ellas (también cambiamos el cursor por una mano). Para ello:

  • definimos en el archivo css la clase css 'clickable'
.clickable {
	cursor: pointer;
}
.clickable:hover { background-position: -90px 0px; }
  • En el .js, cuando entramos en el estado 'playerTurn', añadimos el estilo 'clickable' a las intersecciones donde no hay piedra
        onEnteringState: function( stateName, args )
        {
            console.log( 'Entrando en el estado: '+nombredelestado );
            
            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' );
	                    }
                    }            
            }
        },

Por último, asegúrate de modificar los colores por defecto de los jugadores a blanco y negro

         $default_colors = array( "000000", "ffffff", );

El turno de juego básico está implementado: ¡ya puedes soltar algunas piedras!

Gomoku tuto4.png

Limpiar los estilos

Eliminar los ayudantes de visualización css temporales: ¡se ve bien!

Gomoku tuto5.png

Añadir algunos contadores en los paneles de los jugadores

Edita el .tpl para crear una plantilla para tus contadores.

var jstpl_player_board = '<div class="cp_board">\N-.
    <div id="stoneicon_p${id}" class="gmk_stoneicon gmk_stoneicon_${color}"></div><span id="stonecount_p${id}">0</span>\.
</div>';

Edita .js->setup() para configurar los paneles de los jugadores con esta información extra

            // Configurar los tableros de los jugadores
            for( var player_id in gamedatas.players )
            {
                var player = gamedatas.players[player_id];
                         
                // Configurar los tableros de los jugadores si es necesario
                var player_board_div = $('player_board_'+player_id);
                dojo.place( this.format_block('jstpl_player_board', player ), player_board_div );
            }

Añade algunos estilos en tu .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;
}

En tu .game.php, crea una función que devuelva los contadores del juego

    /*
        getGameCounters:
        
        Reúne todos los contadores relevantes sobre la situación actual del juego (visibles por el jugador actual).
    */
    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) {
    		// Contadores privados de jugadores: concatenar la petición SQL extra con UNION usando el parámetro $player_id
    	}
    
    	return self::getNonEmptyCollectionFromDB( $sql );
    }

Devuelve los contadores de tu juego en tu.game.php->getAllDatas()

        // Contadores
        $resultado['contadores'] = $this->getGameCounters($current_player_id);

Y pasarlos en cualquier notificación que necesite actualizarlos

        // Notificar a todos los jugadores
        self::notifyAllPlayers( "stonePlayed", clienttranslate( '${player_name} dropped a stone ${coordinates}' ), array(
            'player_id' => $player_id,
            'player_name' => self::getActivePlayerName(),
            'coord_x' => $coord_x,
            'coord_y' => $coord_y,
            'color' => $color,
            'counters' => $this->getGameCounters(self::getCurrentPlayerId())
        ) );

Finalmente, en su llamada a la función .js->setup() y al manejador de notificaciones respectivamente

this.updateCounters(gamedatas.counters);

y

this.updateCounters(notif.args.counters);

¡Ahora tienes un contador que funciona!

Gomoku tuto7.png

Implementar reglas y condiciones de fin de partida

Implementa reglas específicas para el juego. Por ejemplo en Gomoku, el negro juega primero. Así que en .game.php->setupNewGame(), al final de la configuración haz que el jugador negro esté activo:

        // El negro juega primero
        $sql = "SELECT player_id, player_name FROM player WHERE player_color = '000000' ";
        $jugador_negro = self::getNonEmptyObjectFromDb( $sql );

        $this->gamestate->changeActivePlayer( $black_player['player_id'] );

Implementar la regla para calcular la progresión del juego en .game.php->getGameProgression(). Para Gomoku usaremos la tasa de intersecciones ocupadas sobre el número total de intersecciones. Esto a menudo será salvajemente inexacto ya que el juego puede terminar bastante rápido, pero es lo mejor que podemos hacer (el juego puede arrastrarse hasta un punto muerto con todas las intersecciones ocupadas y sin ganador).

    function getGameProgression()
    {
        // Calcula y devuelve la progresión del juego

        // Número de piedras colocadas en el goban sobre el número total de intersecciones * 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['valor'];
    }

Implementar la detección del final de la partida y actualizar la puntuación según quién sea el ganador. Es más fácil comprobar una victoria directamente después de establecer la piedra, así que:

  • declara una variable global 'end_of_game' en .game.php->__construct()
       self::initGameStateLabels( array(
                 "end_of_game" => 10,
       ) );
  • init esa variable global a 0 en .game.php->setupNewGame()
       self::setGameStateInitialValue( 'end_of_game', 0 );
  • añadir el código apropiado en .game.php antes de pasar al siguiente estado, utilizando una función checkForWin() implementada por separado para mayor claridad. Si el juego ha sido ganado, establecemos la puntuación, enviamos una notificación de actualización de la puntuación al lado del cliente, y establecemos la variable global 'end_of_game' a 1 como una bandera que señala que el juego ha terminado.
        // Comprueba si se ha cumplido el final de la partida
        if ($this->checkForWin( $coord_x, $coord_y, $color )) {

            // Establece la puntuación del jugador activo en 1 (es el ganador)
            $sql = "UPDATE player SET player_score = 1 WHERE player_id = $player_id";
            self::DbQuery($sql);

            // Notificar la puntuación final
            $this->notifyAllPlayers( "finalScore",
    					clienttranslate( '¡${nombre_del_jugador} gana la partida!' ),
    					array(
    							"nombre_jugador" => self::getActivePlayerName(),
    							"player_id" => $player_id,
    							"score_delta" => 1,
    					)
   			);

            // Establecer la bandera de la variable global para pasar la información de que el juego ha terminado
            self::setGameStateValue('end_of_game', 1);

            // Mensaje de fin de partida
            $this->notifyAllPlayers( "mensaje",
    				clienttranslate('¡Gracias por jugar!'),
    				array(
    				)
    		);

        }
  • Luego en la función gomoku->stCheckEndOfGame() que se llama cuando tu máquina de estados pasa al estado 'checkEndOfGame', comprueba esta variable y otras posibles condiciones de 'fin de juego' (sorteo).
    función stCheckEndOfGame()
    {
        self::trace( "stCheckEndOfGame" );

        $transition = "notEndedYet";

        // Si no hay más intersecciones libres, el juego termina
        $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";
        }        

        // Si se ha establecido la bandera de "fin de juego", termina el juego
        if (self::getGameStateValue('end_of_game') == 1) {
            $transition = "gameEnded";
        }
                
        $this->gamestate->nextState( $transition );
    }
  • Captura la notificación de puntuación en el lado del cliente en .js->setupNotifications(). Se aconseja establecer un pequeño retardo después de eso para que el popup de fin de partida no se muestre demasiado rápido.
                dojo.subscribe( 'finalScore', this, "notif_finalScore" );
	        this.notifqueue.setSynchronous( 'finalScore', 1500 );
  • Implementar la función declarada para manejar la notificación.
            notif_finalScore: function( notif )
	    {
	        console.log( '**** Notificación : finalScore' );
	        console.log( notif );
	      
                // Actualizar la puntuación
                this.scoreCtrl[ notif.args.player_id ].incValue( notif.args.score_delta );
	    },

Prueba todo a fondo... ¡ya has terminado!

Gomoku tuto6.png