Programmare un Gioco Online
Paolo Medici
Questo documenti affronta i problemi teorici e pratici per sviluppare qualsiasi
applicazione su Internet e in particolare Videogiochi. Ho usato terminologie
terra terra sperando di essere più chiaro possibile a un publico
abbastanza ampio. Questo documento spiega come costruire una qualsiasi
applicazione su Internet partendo da zero in pochissimo tempo.
La rete. Terminologia
La rete è tutto ciò che sta in mezzo tra due computer che
vogliono comunicare. Tutto ciò che verrà detto perciò
è uguale sia che si tratti di una LAN (Local Area Network) o che
si tratti di una MAC (Metropolitan Area Network) o WAN (* Area Network).
L'unica differenza sta nella quantità di dati per secondo che queste
diverse reti possono trasportare, per esempio la LAN al giorno d'oggi possono
arrivare fino a 1Gb/sec, le MAN 140Mb/s e le WAN spesso a solo 2Mb/s attraverso
un satelitte. La rete permette di trasferire dati da due computer in modo
trasparente all'utente, in modo tale che questo veda il computer remoto
come direttamente connesso al locale.
L'utente si trova alla cima di una piramide (ISO/OSI), ma anche programmatore
al giorno d'oggi riesce senza troppi problemi a usare una connessione remota
grazie a un set di API comuni su sistemi operativi e macchine diverse come
i Socket.
[continua...]
Usare e Inizializzare la libreria.
Per usare i Socket sotto Win32 è facile: basta linkare la libreria
ws2_32.lib
e includere nel progetto l'header winsock.h o meglio ancora winsock2.h.
Per inizializzare i Socket il procedimento è lo stesso semplice.
Nella fase di startup del programma (in qualunque punto prima di accedere
a funzioni di WinSock) bisogna controllare la versione di Winsock installata:
WSAStartup (uan WORD indicante la versione di WinSock richiesta,
un PUNTATORE A UNA STRUTTURA WSADATA che verrà riempita con informazioni);
esempio:
WSADATA wsadata;
int error = WSAStartup (0x0202, &wsadata);
if (error)
return FALSE;
In questo modo verrà richiesta la versione 2.2 del WinSock.
La libreria verrà inizializzata e sarà possibile lavorare.
Al momento dell'uscita del programma sarà necessario liberare
queste informazioni.
Semplicemente:
WSACleanup();
Queste due funzioni di inizializzazione ovviamente non sono presenti
(ne necessarie) a usare i socket sono Unix/Linux. Sotto queste piattaforme
linkare due librerie: (socket e nls) e includere le librerie:
netinet/in.h, arpa/inet.h (altamente opzionale) e netdb.h.
In generale tutte le funzioni che cominciano con WSA sono Windows Dependent
e non esistono nel subset dei socket della Berkley.
Terminologia.
SOCKET
Un Socket è un descrittore (un numero) che indica una particolare
linea di comunicazione aperta in entrata o in uscita sul proprio computer.
Protocolli
I Protocolli di rete sono strutture in cui i dati vengono trasferite sulla
rete. Non è importante come siano implementate a livello di scheda,
ma piuttosto come questi possono essere utilizzati allo scopo.
Esistono due principali tecniche che si basano su altrettanti protocolli
per far parlare due computer: il TCP o l'UDP. Esistono ovviamente altri
protocolli ma non importanti ai nostri fini. Con questi due protocolli
è possibile sviluppare qualsiasi cosa.
TCP
Il Tcp è il formato più usato su Internet. Lo usano tutti
i Browser, Ftp, Telnet, News, Pop eccetera.
E' un protocollo affidabile e molto rigoroso, ma lento, pesante e poco
flessibile. Il TCP crea un canale di comunicazione bidirezionale tra due
computer e il canale deve restare aperto per tutta la durata della comunicazione,
visto che aprireo e chiudere una sessione TCP è molto lento. Quando
aperta i dati trasferiti e ricevuti sono affidabili, i pacchetti arrivano
sempre a destinazione nell'ordine di invio. Questo perchè il TCP
aspetta un segnale di ricezione prima di trasmettere i dati successivi.
Questo protocollo va bene fino a un centinaio di connessioni contemporaneamente.
Per creare un SOCKET TCP (anche chiamato Stream Socket) basta scrivere:
SOCKET s = socket (AF_INET, SOCK_STREAM, 0);
La variabile tipo SOCKET sotto Unix non esiste: o si usa int
al suo posto o si fa un elegante typedef:
typedef SOCKET int;
UDP
L'UDP invece è totalmente l'opposto. E' un protocollo veloce, ad
alte prestazione, ma inaffidabile. Ovvero i pacchetti possono andar perduti
nella comunicazione, arrivare addirittura doppi, o arrivare in tempi diversi
rispetto a quelli di invio. Il vantaggio è che non viene aperto
un canale privato tra due computer, ma i pacchetti viaggiano indisturbati
sulla rete. Il computer che trasmette non aspetta che i pacchetti siano
arrivati, ma si limita solo a trasmetterli. In questo modo sul computer
che trasmette non c'è bisogno di avere aperte centinaia di comunicazioni,
ma al momento dell'invio dei dati basta indicare il codice del computer
a cui si vuole inviare il pacchetto e sarà poi compito della Rete
farglielo arrivare il prima possibile. In questo modo si possono avere
infinite connessioni contemporaneamente senza vedere un appesantimento
(fisso) delle prestazioni sulla comunicazione (indipendentemente dalla
quantità dei dati trasferiti).
Per creare un SOCKET UDP (anche chiamato Datagram Socket) basta scrivere:
SOCKET s = socket (AF_INET, SOCK_DGRAM, 0);
La dimensione del singolo pacchetto è importante
(anche perchè l'UDP, all'opposto del TCP, rispetta le dimensioni
del dato inviato). Più il pacchetto è grande meno sono le
probabilità che arrivi a destinazione. Una dimensione di 2K per
un pacchetto, da test empirici, da me effettuati è stata l'ultima
affidabile (con banda di uscita uguale alla massima supportata dal modem).
Per esempio inviare un pacchetto di 8K arriva a un destinatario distante
4/5 nodi con probabilità del 33%. Il metodo più rapido per
rendere affidabile l'UDP è spedire più pacchetti uguali (il
destinatario dovrà avere un sistema per eliminare pacchetti già
ricevuti, per esempio tramite un ID inviato con il pacchetto), sempre che
questo procedimento non si scontri con la limitatezza di banda in uscita
del server.
Multicast
Il Multicast è una tecnica che permette di ridurre drasticamente
la larghezza delle banda utilizzata in uscita da un Server. Questa tecnica
è applicata al caso di trasmissioni a un elevato numero di utenti
dello stesso pacchetto di dati. Invece che inviare a ogni utente
il pacchetto (cosa che occuperebbe n*size) si invia al router della rete
il pacchetto con le indicazioni delle destinazioni e sarà compito
del router e dei router che seguiranno smistare correttamente il dato ai
destinatari. Ovviamente questa tecnica è ottima per trasmissioni
video, dove l'utente ha una scarsa interazione con il server e il server
deve trasmettere a molti utenti una quantità elevata di dati. A
mio parere in un gioco queste condizioni non si verificano quasi mai...
comunque potrebbe divenire vantaggioso spedire agli utenti anche dati non
voluti, ma che in seguito potrebbero desiderare... bof...
Come implementarlo: appena sono sicuro ve lo vaccio sapere... in reti
locali è stato provato e funzionate.... su Internet non tutti i
nodi sono stati ancora progettatti per il multicast e prove non sono ancora
state possibili.
[continua...]
Indirizzo IP
L'indirizzo IP è un array di numeri (4 o 8 a seconda della versione)
che indicano un'unica macchina su tutta la rete (LAN o WAN che essa sia).
Alcuni indici sono riservati per uso interno del computer o della rete.
E' da tenere a mente che questo numero può essere fisso o assegnato
di volta in volta.
DNS
Il DNS (Domain Name Server) è un servizio svolto a livello di rete
(locale o globale) che permette di convertire una stringa nell'indirizzo
IP del computer corrispondente. Questo servizio è disponibile direttamentre
via software (gethostbyname) ed è molto semplice da implementare.
Client e Server
Il Server è il computer che riceve e invia i dati di computer Client
collegati tra di loro. I Client non si vedono tra di loro in un'architettura
tra Client e Server, ma è il Server centrale che smista i messaggi
tra loro, filtrandoli o modificandoli.
Servizi e porte
I Servizi sono programmi che si mettono in attesa di connessioni sul Server.
Questi programmi si mettono in ascolto su una porta (virtuale) di
comunicazione sul computer. Una porta è un numero (0-65535) che
indica un unico servizio su quel computer e su un computer possono aperte
quante porte (diverse) si vogliono. In questo modo è possibile che
più programmi (servizi) possano girare sullo stesso computer contemporaneamente
anche se svolgono funzioni nettamente diverse con la rete. Per esempio
un Server può fare da Server TCP, HTTP, Telnet contemporaneamente,
anche se i programmi inviano dati differenti allo stesso indirizzo IP.
Il Servizio a cui si vuole parlare non può essere cambiato durante
una comunicazione neanche usando l'UDP. Quando selezionato sul Client e
messo in ascolto sul Server i dati verranno inviati automaticamente all'applicazione
corretta senza nessun controllo da parte dell'utente. L'applicazione Client
e l'applicazione Server devono ovviamente parlare usando lo stesso protocollo.
Alcuni numeri di porte sono riservati, comunque i numeri sopra il 1024
non sono normalmente riservati a servizi specifici.
Alcuni esempi (le definizioni possono essere trovate anche in winsock.h):
Porta |
Servizio |
21 |
Telnet. |
25 |
SMTP |
80 |
HTTP |
Per tutti questi servizi è sottointeso che venga usato il protocollo
TCP.
Close Socket
Per chiudere un descittore, precedentemente creato con socket,
sotto Windows si usa la funzione closesocket, mentre, visto che
in Unix il socket è un descrittore standard di sistema, si usa la
close standard.
Collegare il Client
Dopo aver inizializzato la libreria, e creato un socket con il protocollo
scelto (il Client e il Server devono usare lo stesso protocollo), per collegare
i due computer biosgna sapere 2 cose:
-
L'indirizzo IP o il nome del computer remoto
-
La porta in cui il programma sul Server è in Ascolto
Questi dati sono imposti da chi ha programmato il programma Server e il
nome o l'indirizzo IP devono essere Fissi (o comunicati in altro modo all'utente).
(la variabile s (di tipo SOCKET) deve essere inizializzata con
il protocollo scelto)
(server_name (stringa) deve contenere il nome o l'indirizzo
IP del server)
(wPort (WORD) deve contenere il numero della porta del servizio)
sockaddr_in target;
u_long addr = inet_addr (server_name);
if (addr == INADDR_NONE) {
// server_name non è un indirizzo
IP, proviamo a usare il DNS
hostent* HE = gethostbyname(server_name);
if (HE == 0) {
closesocket(s);
// Errore: Host non trovato
return INVALID_SOCKET;
}
addr = *((u_long*)HE->h_addr_list[0]);
}
Adesso addr contiene un numero che è l'indirizzo IP dell'Host.
La procedura successiva collega il Client al Server. Se si stà usando
UDP questa procedura non è necessaria, ma comunque non da errore.
L'unica comodità in questo modo per UDP è che si può
usare send invece di sendto, visto che in questo modo si
seleziona il server di default per inviare i messaggi.
target.sin_family = AF_INET;
// address family Internet
target.sin_port = htons (wPort); //
set server’s port number
target.sin_addr.s_addr = addr; // set
server’s IP
if (connect(s, (SOCKADDR*) &target, sizeof(target)) == SOCKET_ERROR)
{
// Errore di comunicazione
closesocket(s);
return INVALID_SOCKET;
}
La funzione htons (Host to Network) converte un numero nel
formato del computer locale in quello della rete (è stato scelto
il Big-Endian). Anche se si sa che il computer locale è Big-Endian
è ottima cosa usare lo stesso questa funzione, che in tal caso sarà
solo una macro vuota.
Conclusione. Il client deve Connettersi con un Server: ConnectToServer
SOCKET ConnectToServer(char *server_name, WORD
wPort)
{
#ifdef USEUDP
SOCKET s = socket (AF_INET, SOCK_DGRAM,
0); // Create Datagram Socket
#else
SOCKET s = socket (AF_INET, SOCK_STREAM,
0); // Create Stream Socket
#endif
sockaddr_in target;
u_long addr = inet_addr (server_name);
if (addr == INADDR_NONE) {
// Host isn't an IP address, try using
DNS
hostent* HE = gethostbyname(server_name);
if (HE == 0) {
closesocket(s);
// error: Unable to parse!
return INVALID_SOCKET;
}
addr = *((u_long*)HE->h_addr_list[0]);
}
target.sin_family = AF_INET;
// address family Internet
target.sin_port = htons (wPort);
// set server’s port number
target.sin_addr.s_addr = addr; //
set server’s IP
if (connect(s, (SOCKADDR*) &target, sizeof(target))
== SOCKET_ERROR)
{
// an error connecting has occurred!
closesocket(s);
return INVALID_SOCKET;
}
return s;
}
Socket
I Socket permettono 3 divesi metodi per gestire i dati che arrivano dalla
rete. Infatti il programma deve continuare a eseguire operazioni quando
non arrivano informazioni dalla rete e in ogni caso il sistema operativo
deve restare in esecuzione.
Dunque si è dovuto implementare diversi metodi per essere più
o meno asincroni rispetto agli eventi della rete:
-
Socket Sincroni (bloccanti) su Thread separati dal programma principale
-
Socket Asincroni che inviano messaggi a una Window di sistema quando arrivano
dati
-
Socket che generano Eventi e vengono gestiti come tali
Ovviamente non esiste una configurazione ottimale ma dipende dal tipo di
programma ed è diversa tra Client e Server.
Solitamente sul Server si scelgono socket bloccanti e sul client asincroni,
ma è solo una traccia.
IO di base
Le funzioni di IO di base sono molto semplici: recv e send
per il TCP e recvfrom e sendto specifici l'UDP,
ma non è sempre vero...
Ecco i prototipi di queste funzioni:
int recv (
SOCKET s,
char FAR* buf,
int len,
int flags
); |
int recvfrom (
SOCKET s,
char FAR* buf,
int len,
int flags,
struct sockaddr FAR* from,
int FAR* fromlen
); |
int send (
SOCKET s,
const char FAR * buf,
int len,
int flags
); |
int sendto (
SOCKET s,
const char FAR * buf,
int len,
int flags,
const struct sockaddr FAR * to,
int tolen
); |
Vediamo in dettaglio (a mio avviso) quali funzioni è meglio usare:
Funzione |
Significato |
Client |
Server |
recv |
riceve i dati da un socket da cui non è importante conosce l'indirizzo. |
Visto che i dati dovrebbero arrivare sempre dal server questa funzione
è sufficiente. |
(TCP) E' utile.
(UDP) Non fornisce sufficienti informazioni. |
recvfrom |
Riceve i dati e fornisce l'indirizzo del computer che li ha inviati |
(TCP) Non è utile. (UDP) Comunicazioni Peer To Peer |
(UDP) E' sempre necessario conoscere quale computer invia i dati.
(TCP) E' inutile. L'indirizzo è ridondante. |
send |
Invia dati all'indirizzo indicato dal socket. |
E' sufficiente. Il socket contiene le informazioni per comunicare con
il server. |
(TCP) E' indispensabile.
(UDP) è limitato: invia a un solo target |
sendto |
Invia dati a un indirizzo specificato. |
(TCP) Non è utile. (UDP) Comunicazioni Peer To Peer |
(TCP) L'indirizzo viene ignorato.
(UDP) E' l'unica via per comunicare con i client. |
Ovviamente se si utilizzano comunicazioni peer-to-peer è
necessario sempre usaro recvfrom e sendto.
Sotto Unix, visto che i Socket sono sempre descrittori, è possibile
usare le funzioni read e write tipiche delle operazioni su
file (o stream in generale), ovviamente solo se il protocollo utilizzato
è di tipo STREAM (come lo è ovviamente il TCP). Sotto Windows,
d'altro canto, i Socket sono degli Handle, e rende possibile usare le funzioni
ReadFile e WriteFile, con gli stessi accorgimenti di avere
una connessione stream.
Socket Sincroni. Server.
Sul server è utile creare un Thread a parte per la ricezione dei
messaggi dagli utenti.
int ServerThreadUdp(SOCKET sServer)
{
struct sockaddr_in from;
int fromlen;
int retval;
fromlen =sizeof(from);
retval = recvfrom(sServer,
recbuffer,
RECBUFFER_SIZE,
0,
(struct sockaddr *)&from,
&fromlen);
... etc ...
}
Il server in ogni caso è un tipico esempio di MultiThreading
e di sincronia dei dati. Mentre un thread è in ascolto, un'altro
deve compiere delle azioni e notificarle man mano agli utenti, basandosi
su dati che nel frattempo potrebbero essere stati modificati.
Se per caso vogliamo sviluppare un gioco di ruolo a turni (tipo ogni
10 secondi) potremo creare un Timer che viene chiamato con questa cadenza
e da questo trasmettere agli utenti i risultati delle azioni.
[continua]
Socket Asincroni. Client e UDP.
Cominciamo subito con un esempio pratico. Sono necessarie 3 cose:
-
SOCKET sServer. Un socket connesso con un server, per esempio tramite la
precedente procedura ConnectToServer.
-
HWND hWnd. La finestra principale del programma a cui giungeranno messaggi
di notifica quando arriveranno dei dati al programma
-
una costante, WM_ONSOCKET, una macro come in questo caso (per esempio
WM_USER + 1), che indentifica il codice messaggio che verrà inviato
alla finestra.
WSAAsyncSelect (sServer, hWnd, WM_ONSOCKET, (FD_READ
| FD_CONNECT | FD_CLOSE));
In questo caso, quando arriveranno dei dati (FD_READ), o se verrà
Chiusa la connessione dal Server, verrà generato un messaggio chiamato
WM_ONSOCKET e inviato alla finestra hWnd. Ricordo ancora che WM_ONSOCKET
è una macro definita dall'utente del tipo:
#define WM_ONSOCKET (WM_USER+1)
A questo punto basta aggiungere alla procedura di controllo dei messaggi:
case WM_ONSOCKET:
if (WSAGETSELECTERROR(lParam))
{
// error
OnSocketError(hwnd,WSAGETSELECTERROR(lParam));
return 0;
}
switch (WSAGETSELECTEVENT(lParam))
{
case FD_READ:
OnReceiveData(hwnd, (SOCKET)wParam);
break;
case FD_CONNECT:
break;
case FD_CLOSE:
OnSocketClose(hwnd, (SOCKET)wParam);
break;
}
return 0;
OnReceiveData sarà una funzione del tipo:
int OnReceiveData(HWND hwnd, SOCKET s)
{
int iLen;
iLen = recv(s, (char *) recbuffer, RECBUFFER_SIZE, 0);
if(iLen == SOCKET_ERROR)
// error
return FALSE;
... etc ...
return TRUE;
}
mentre OnSocketClose verrà chiamato quando l'utente è
stato disconnesso dal server: sarà una funzione del tipo:
void OnSocketClose(HWND hwnd, SOCKET s)
{
// flush data:
while(OnReceiveData(hwnd, s)>0);
}
Paolo Medici, che ultimamente aveva del tempo da perdere in retorica.
Dalla Serie delle Guide Veloci per fare Software Miliardari |
|
Queste sono considerazioni personali, dettate dall'esperienza pratica
in anni di programmazione. Se avete domande o modifiche:
|
Questo articolo è ha avuto
16257
contatti. Pagina Aggiornata il 14 giugno 2001