online_leaderboard_tutorial_1

en Tutoriales, UE4

Cómo crear una tabla de clasificación online (Parte 1)

Una tabla de clasificación es algo obligatorio en prácticamente cualquier juego online actual, con este tutorial vamos a aprender a crear y configurar un sistema con tabla de puntuaciones, en las primeras dos partes hablaremos, intentando que sea lo más independiente del motor posible, de lo relacionado con el servidor y el cliente, y en la tercera parte nos centraremos en la integración con el Unreal engine 4.

Parte 1: Archivos del servidor
Parte 2: Archivos del cliente
Parte 3: Integración con el proyecto UE4

Una tabla de clasificación es una buena manera de incentivar al jugador a repetir una zona o nivel aprovechándonos de su naturaleza competitiva.

Una tabla online tiene dos partes principales. La primera de ellas está relacionada con el lado del servidor, necesitamos tener algún equipo o sitio que tenga conexión online, puedes alquilar un hosting web por pocos dolares al año o prepararte uno en tu propia casa. La parte del servidor es la encargada de almacenar y actualizar los datos relacionados con las puntuaciones por lo que necesitaremos dotar a nuestro sistema de una base de datos también.

client-server-squema

Si quieres probar la parte del servidor en un entorno local antes de subirlo a un sitio online puedes utilizar un servidor WAMP. Este paquete incorpora todas las herramientas necesarias para montar un sitio online en tu propia máquina. Mi favorito es Uniform Server, no requiere de instalación, solo desempaquetar y utilizar.

Base de datos

La primera tarea es decidir que datos vamos a utilizar para establecer el liderazgo de la tabla. Puede ser un simple número para representar la puntuación, o una tupla para ordenar por nivel y puntuación, hay un montón de posibilidades, además de esto, necesitamos almacenar algunos datos más, como el nombre del jugador y un ID único para identificarlo.

Ya podemos abrir la herramienta phpMyAdmin y crear una base de datos (TGames) y después añadir dos tablas.  La primera para almacenar los datos relacionados con la cuenta de usuario (GAME1_USERS) y la segunda para la lista de puntuaciones (GAME1_LEADERBOARD). Intenta no crear una base de datos por cada juego, en muchos hostings están limitadas, en vez de eso crea solo una base de datos de juegos y pon un mismo prefijo a todas las tablas relacionadas con un mismo juego.

game_database

En este ejemplo vamos a utilizar la tabla de usuario solo como un contador de usuarios, este contador se utilizará durante la generación del Id único. Podemos establecer el campo userCount como un valor AUTO_INCREMENT.

table_users

En la tabla de puntuaciones vamos a guardar el Id único de usuario, el nombre que se mostrará en el registro, la puntuación máxima de ese jugador, y un campo para controlar la fecha del envío (Podríamos crear una tabla por mes o semana filtrando con ese valor)

table_leaderboard

Archivos PHP

El primer archivo php está relacionado con la conexión a la base de datos, podemos dividirlo en dos archivos, config.php y connectdb.php, el primero para guardar los parámetros de configuración de la base de datos  y el segundo para gestionar la conexión mysql.

config.php

<?php
// Database config variables
define("DB_HOST", "localhost");
define("DB_USER", "your_user_name");//database username
define("DB_PASSWORD", "your_user_password");//database user password
define("DB_DATABASE", "your_database");//database name
?>

connectdb.php

<?php
class DB_Connector {
 
    // constructor
    function __construct() {}
 
    // destructor
    function __destruct() {}
 
    // Connecting to database
    public function connect() {
        require_once 'config.php';
        // connecting to mysql
        $con = mysqli_connect(DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE);
 
        // return database handler
        return $con;
    }
 
    // Closing database connection
    public function close() {
        mysqli_close();
    }
}
?>

El tercero gameFunctions.php se utilizará para contener las funciones que pueden ser llamadas por nuestro cliente. En el constructor de la clase podemos añadir el constructor del conector de base de datos definido antes.

Necesitamos dos funciones principalmente, una para enviar la puntuación máximo del jugador y otra para descargar la tabla de clasificación..

Función SendScore

Si el jugador tiene un userId asociado necesitamos buscar su registro previo y actualizar sus datos. Podemos comprobar si a batido su record en este punto o/y hacerlo en el client también y evitar así la llamada si no lo ha hecho. Si el jugador no tiene userId necesitamos generar uno antes de insertar el registro y devolver ese valor al cliente para que pueda guardarlo en el dispositivo del jugador.

public function sendScore($userId, $userName, $score, $userData)
{
	if ($userId)
	{
		//the user has an ID assigned
		$result = mysqli_query($this->connection, "SELECT GAME1_LEADERBOARD.score FROM GAME1_LEADERBOARD WHERE GAME1_LEADERBOARD.userId='$userId'");
		if ($result)
		{
			//we have a leaderboard row for the current user
			$rowDataUser = mysqli_fetch_row($result);
			$oldscore = $rowDataUser[0];
			if ($oldscore < $score)
			{
				$result2 = mysqli_query($this->connection, "UPDATE GAME1_LEADERBOARD SET GAME1_LEADERBOARD.score='$score',GAME1_LEADERBOARD.userName='$userName' WHERE GAME1_LEADERBOARD.userId='$userId'");
				if ($result2)
				{
					return array ('0', $userId); // all OK
				}
				else
				{
					return array ('1', "Error Code 1: update fail"); //error during update query
				}
			}
			else
			{
				return array ('0', $userId);// dont need update, the new score is lower than the old score
			}
		}
		else
		{
			//we need to insert a leaderboard row for the current user
			$result = mysqli_query($this->connection, "INSERT INTO GAME1_LEADERBOARD (userId,userName,score,lastDate) VALUES('$userId','$userName','$score',SYSDATE())");
			if ($result)
			{
				return array ('0', $userId); // all OK.
			}
			else
			{
				return array ('2', "Error Code 2: insert fail");//error during leaderboard insertion
			}
		}
	}
	else
	{
		// Is a new user			
		// generates a random userid , inserts score and returns the generated userid
		$result = mysqli_query($this->connection, "INSERT INTO GAME1_USERS (userName) VALUES('$userName')");
		if ($result)
		{
			$result2 = mysqli_query($this->connection, "SELECT LAST_INSERT_ID()");
			if ($result2)
			{
				$rowDataUser = mysqli_fetch_row($result2);
				$userCount=$rowDataUser[0];
				
				$userChain = '$userName'.date(DATE_RFC2822).'$userCount';
				$newuserId = md5($userChain);
				
				$result3 = mysqli_query($this->connection, "INSERT INTO GAME1_LEADERBOARD (userId,userName,score,lastDate) VALUES('$newuserId','$userName','$score',SYSDATE())");
				if ($result3)
				{
					return array ('0', $newuserId); // all OK. Returns the generated userID
				}
				else
				{
					return array ('3', "Error Code 3: insert fail");
				}
			}
			else
			{
				return array ('4', "Error Code 4: DB error"); // LAST_INSERT_ID is empty
			}
		}
		else
		{
			return array ('5', "Error Code 5: insert fail"); // new user insertion fail
		}
	}
}
Función GetLeaderboard

Vamos a devolver un array con un top de las 100 mejores puntuaciones y un registro más con la información de jugador que lo solicita. Durante la extracción de estos datos el cliente debe gestionar esta estructura para mostrar solo los datos que necesita para el estado actual de la pantalla, si es que muestra una lista con varias páginas.

public function getLeaderboard($userId)
{
	$result = mysqli_query($this->connection, "SELECT GAME1_LEADERBOARD.userName,GAME1_LEADERBOARD.score,GAME1_LEADERBOARD.userId FROM GAME1_LEADERBOARD ORDER BY GAME1_LEADERBOARD.score DESC");
	if ($result)
	{
		$stack = array();
		$count = 1;
		
		$bestScore = 0;
		$bestPosition = 0;
		$bestName = null;
		while (($rowData = mysqli_fetch_row($result)) and ($count < 101)) // retrieves only the top 100 list
		{
			$rowUserName=$rowData[0];
			$rowScore=$rowData[1];
			array_push($stack, array($count,$rowUserName,$rowScore));
			if ($rowData[2] == $userId) // if the current userID is in the list, an extra record is returned to show the user info
			{
				$bestName = $rowData[0];
				$bestScore = $rowData[1];
				$bestPosition = $count;
			}
			
			++$count;
		}
		
		array_push($stack, array($bestPosition,$bestName,$bestScore));
		return array ('0', $stack);
	}
	else
	{
		return array ('6', "Error Code 6: retrieving list fail");
	}
}

Ahora podemos poner ambas funciones juntas y añadir el contenedor de llamadas. En este caso es un switch con las llamadas a nuestras funciones, vamos a utilizar el nombre de la función como parámetro en la llamada desde el cliente. Esto nos permitirá tener un código mas fácil de reutilizar en un futuro.

gameFuntions.php

<?php
$funcName = $_POST['funcName'];
 
$userId= isset($_POST['userId']) ? $_POST['userId'] : '';
$userName= isset($_POST['userName']) ? $_POST['userName'] : '';
$score= isset($_POST['score']) ? $_POST['score'] : '';
$userData= isset($_POST['userData']) ? $_POST['userData'] : '';

$functions = new gameFunctions();
// select function to execute
switch ($funcName) 
{
	case "sendScore":	list ($returnCode, $returnData) = $functions->sendScore($userId, $userName, $score, $userData); break;
	case "getLeaderboard":	list ($returnCode, $returnData) = $functions->getLeaderboard($userId); break;
	default:
		list ($returnCode, $returnData)  =  array('100', "Unknown funtion name"); break;
}

$resultado = array("returnCode"=>$returnCode, "returnData"=>$returnData);

echo json_encode($resultado);


class gameFunctions
{
	private $db;
	private $connection;
	
	// constructor
	function __construct() 
	{
		require_once 'connectdb.php';
		// connecting to database
		$this->db = new DB_Connector();
		$this->connection = $this->db->connect();
	}
	
	// destructor
	function __destruct()
	{
		$this->db->close();
	}
	
	public function sendScore($userId, $userName, $score, $userData)
	{
		// previous code go here
	}
	
	public function getLeaderboard($userId)
	{
		// previous code go here
	}	  
} 
?>

Anexo de seguridad

Antes de terminar con los archivos php podemos incorporar un poco de seguridad a nuestro código para evitar las trampas más básicas. Podemos comprobar si los datos han sido manipulados en el envío generando un hash para la estructura de datos recibida y compararla con el hash enviado por el cliente junto con esa misma estructura de datos. En este ejemplo nuestra estructura de datos es el resultado de aplicar la concatenación de los datos de la puntuación junto a un par de palabras secretas. Esas mismas palabras deben estar también en la parte de código del cliente. El cliente genera el hash para los datos + las palabras secretas y envía sólo los datos junto a ese hash al servidor. Nuestro hash será enviado en el parámetro userData.

Finalmente el gameFuntions.php se parecerá a esto:

<?php
$funcName = $_POST['funcName'];
 
$userId= isset($_POST['userId']) ? $_POST['userId'] : '';
$userName= isset($_POST['userName']) ? $_POST['userName'] : '';
$score= isset($_POST['score']) ? $_POST['score'] : '';
$userData= isset($_POST['userData']) ? $_POST['userData'] : '';

$functions = new gameFunctions();
// select function to execute
switch ($funcName) 
{
	case "sendScore":	list ($returnCode, $returnData) = $functions->sendScore($userId, $userName, $score, $userData); break;
	case "getLeaderboard":	list ($returnCode, $returnData) = $functions->getLeaderboard($userId); break;
	default:
		list ($returnCode, $returnData)  =  array('100', "Unknown funtion name"); break;
}

$resultado = array("returnCode"=>$returnCode, "returnData"=>$returnData);

echo json_encode($resultado);


class gameFunctions
{
	private $db;
	
	// constructor
	function __construct() 
	{
		require_once 'connectdb.php';
		// connecting to database
		$this->db = new DB_Connector();
		$this->db->connect();
	}
	
	// destructor
	function __destruct()
	{
		$this->db->close();
	}
	
	public function sendScore($userId, $userName, $score, $userData)
	{
		if ($userId)
		{
			//the user has an ID assigned
			$tocheck = hash('sha256', $userId.$userName."secret1".$score."secret2");
			if ($tocheck != $userData)
				return array ('32', "Error Code 32: invalid user data");

			$result = mysqli_query($this->connection, "SELECT GAME1_LEADERBOARD.score FROM GAME1_LEADERBOARD WHERE GAME1_LEADERBOARD.userId='$userId'");
			if ($result)
			{
				//we have a leaderboard row for the current user
				$rowDataUser = mysqli_fetch_row($result);
				$oldscore = $rowDataUser[0];
				if ($oldscore < $score)
				{
					$result2 = mysqli_query($this->connection, "UPDATE GAME1_LEADERBOARD SET GAME1_LEADERBOARD.score='$score',GAME1_LEADERBOARD.userName='$userName' WHERE GAME1_LEADERBOARD.userId='$userId'");
					if ($result2)
					{
						return array ('0', $userId); // all OK
					}
					else
					{
						return array ('1', "Error Code 1: update fail"); //error during update query
					}
				}
				else
				{
					return array ('0', $userId);// dont need update, the new score is lower than the old score
				}
			}
			else
			{
				//we need to insert a leaderboard row for the current user
				$result = mysqli_query($this->connection, "INSERT INTO GAME1_LEADERBOARD (userId,userName,score,lastDate) VALUES('$userId','$userName','$score',SYSDATE())");
				if ($result)
				{
					return array ('0', $userId); // all OK.
				}
				else
				{
					return array ('2', "Error Code 2: insert fail");//error during leaderboard insertion
				}
			}
		}
		else
		{
			// Is a new user
			$tocheck = hash('sha256', $userName."secret1".$score."secret2");
			if ($tocheck != $userData)
				return array ('31', "Error Code 31: invalid user data");
				
			//generates a random userid , inserts score and returns the generated userid
			$result = mysqli_query($this->connection, "INSERT INTO GAME1_USERS (userName) VALUES('$userName')");
			if ($result)
			{
				$result2 = mysqli_query($this->connection, "SELECT LAST_INSERT_ID()");
				if ($result2)
				{
					$rowDataUser = mysqli_fetch_row($result2);
					$userCount=$rowDataUser[0];
					
					$userChain = '$userName'.date(DATE_RFC2822).'$userCount';
					$newuserId = md5($userChain);
					
					$result3 = mysqli_query($this->connection, "INSERT INTO GAME1_LEADERBOARD (userId,userName,score,lastDate) VALUES('$newuserId','$userName','$score',SYSDATE())");
					if ($result3)
					{
						return array ('0', $newuserId); // all OK. Returns the generated userID
					}
					else
					{
						return array ('3', "Error Code 3: insert fail");
					}
				}
				else
				{
					return array ('4', "Error Code 4: DB error"); // LAST_INSERT_ID is empty
				}
			}
			else
			{
				return array ('5', "Error Code 5: insert fail"); // new user insertion fail
			}
		}
	}
	
	public function getLeaderboard($userId)
	{
		$result = mysqli_query($this->connection, "SELECT GAME1_LEADERBOARD.userName,GAME1_LEADERBOARD.score,GAME1_LEADERBOARD.userId FROM GAME1_LEADERBOARD ORDER BY GAME1_LEADERBOARD.score DESC");
		if ($result)
		{
			$stack = array();
			$count = 1;
			
			$bestScore = 0;
			$bestPosition = 0;
			$bestName = null;
	 		while (($rowData = mysqli_fetch_row($result)) and ($count < 101)) // retrieves only the top 100 list
	 		{
	 			$rowUserName=$rowData[0];
	 			$rowScore=$rowData[1];
	 			array_push($stack, array($count,$rowUserName,$rowScore));
	 			if ($rowData[2] == $userId) // if the current userID is in the list, an extra record is returned to show the user info
	 			{
	 				$bestName = $rowData[0];
	 				$bestScore = $rowData[1];
	 				$bestPosition = $count;
	 			}
	 			
	 			++$count;
			}
			
			array_push($stack, array($bestPosition,$bestName,$bestScore));
			return array ('0', $stack);
		}
		else
		{
			return array ('6', "Error Code 6: retrieving list fail");
		}
	}	  
} 
?>

La última tarea es subir los archivos php a nuestro servidor y tomar nota de la URL del archivo gameFunctions.php.

Tutorial files

Ahora podemos continuar con el código de la parte cliente. Cómo crear una tabla de clasificación online (Parte 2)

2021/10/05 – Updated to PHP 7

Ayudanos con este blog!

El último año he estado dedicando cada vez más tiempo a la creación de tutoriales, en su mayoria sobre desarrollo de videojuegos. Si crees que estos posts te han ayudado de alguna manera o incluso inspirado, por favor considera ayudarnos a mantener este blog con alguna de estas opciones. Gracias por hacerlo posible!

Escribe un comentario

Comentario