09 mayo 2008

Programar videojuegos con la librería SDL (3)

Después de crear el núcleo central del programa vamos a ver como cargar los gráficos para su posterior visualización.

LOS SPRITES

En un videojuego en dos dimensiones (2D) los protagonistas principales son los llamados Sprites. Un sprite es una figura gráfica en movimiento que puede tener una o más posiciones (frames). Por ejemplo, en un juego de naves tipo Galaxian, la nave principal sólo tiene una posición: la vista desde arriba. En cambio en un juego de plataformas el personaje tiene muchas posiciones (andar, saltar, agacharse, etc.).

Cuando yo era novato en estos temas, lo que hacía era crear un sprite por cada personaje y posición. Eso formaba una cantidad de ficheros gráficos impresionante donde al final no me aclaraba que archivo iba para cada posición.

Otros programadores suelen guardar todos los gráficos en una sola imagen y van capturando sólo el trozo que les interesa:


Si el juego esta planificado al máximo desde el principio entonces ésta es la solución ideal. Pero como los seres humanos somos perezosos por naturaleza, empezamos las cosas con un planteamiento inicial vago y luego decimos: ¿y si añadimos esto? ¿y si añadimos lo otro? Pasa lo mismo que con los programas de gestión, empiezas a improvisar un poco por aquí y un poco por allá y de un proyecto serio acaba convertido en churros con chocolate.

Así que me decanté por una solución intermedia que me permitiera improvisar en el futuro: cada sprite en su fichero pero con todas sus posiciones. Así es como lo hacen hoy en día la mayoría de juegos 3D (por lo menos así pude verlo en el código fuente de Quake 3).

LA CLASE TSPRITE

Dentro de nuestra unidad genérica UJuego.pas vamos a crear una clase para manejar todo lo que necesita un sprite. La clase la vamos a llamar TSprite:

TSprite = class
sNombre: string; // Nombre del archivo del sprite
rx, ry: Real; // Coordenadas del sprite en coma flotante
x, y: Integer; // Coordenadas del sprite en pantalla
iAncho, iAlto: Integer; // Altura y anchura del sprite
bVisible, bTransparente: Boolean;
bSubsprites: Boolean; // ¿Tiene subsprites?
iNumSubX, iNumSubY: Integer; // Nº de subsprites a lo ancho y a lo alto
iAnchoSub, iAltoSub: Integer; // Ancho y alto del subsprite
iSubX, iSubY: Integer; // Coordenada del subsprite dentro del sprite
Superficie: PSDL_Surface;

constructor Create;
destructor Destroy; override;
procedure CargarSuperficie( sArchivo: string );
procedure Transparente;
procedure NoTransparente;
procedure Dibujar;
end;


Veamos detenidamente para que sirve cada variable:

sNombre: nombre del archivo BMP asociado al sprite.
rx, ry: coordenadas del sprite en pantalla en números reales.
x,y: coordenadas reales del sprite en pantalla (transformadas de rx y ry).
iAncho, iAlto: ancho y alto del sprite en pixels.
bVisible: ¿Está visible en pantalla?
bTransparente: En un sprite transparente sólo son transparentes ciertas zonas del sprite.

Muchos os preguntaréis ¿Qué hace este tío guardando las coordenadas del sprite en formato flotante si en la pantalla se guardan las coordenadas como números enteros?. La explicación es sencilla: permite controlar la velocidad de movimiento con más precisión y es imprescindible cuando vamos a crear rutinas físicas para controlar la gravedad, la aceleración, etc.

Para solucionar el tema de que un sprite pueda tener muchas posiciones, se me ocurrió el crear subsprites dentro del sprite. Es como dividir un sprite en trozos horizontal y verticalmente:


Para ello utilizo estas variables dentro de la clase TSprite:

bSubsprites: ¿Tiene subsprites o es un sprite simple? Por defecto está a False.
iNumSubX, iNumSubY: Nº de subsprites horizontal y verticalmente.
iAnchoSub, iAltoSub: Ancho y alto máximo de cada uno de los subsprites.
iSubX, iSubY: subsprite seleccionado actualmente (columna y fila)
Superficie: Es el bitmap en memoria de video que contiene la imagen BMP cargada.

Vamos a ver la hora la implementación de la clase TSprite. Primero tenemos el constructor que inicializa todos los valores:

constructor TSprite.Create;
begin
Superficie := nil;
bVisible := True;
bTransparente := True;
rx := 0;
ry := 0;
x := 0;
y := 0;
iAncho := 0;
iAlto := 0;
bSubsprites := False;
iNumSubX := 0;
iNumSubY := 0;
iAnchoSub := 0;
iAltoSub := 0;
iSubX := 0;
iSubY := 0;
end;

Depués tenemos el desctructor que se encargar de eliminar la superficie (si esta cargada en memoria de vídeo):

destructor TSprite.Destroy;
begin
// Destruimos la superficie cargada
if Superficie <> nil then
begin
SDL_FreeSurface( Superficie );
Superficie := nil;
end;

inherited;
end;

Como las superficies son objetos especiales creados por la librería SDL no podemos llamar al método Free o FreeAndNil como si tal cosa. Hay que llamar a la función SDL_FreeSurface que se encarga de liberarla ya sea de la memoria de vídeo o de la memoria RAM.

Lo siguiente que necesitamos es la rutina que carga el sprite BMP del disco duro:

procedure TSprite.CargarSuperficie( sArchivo: string );
begin
Superficie := SDL_LoadBMP( PChar( sArchivo ) );

if Superficie = nil then
begin
ShowMessage( 'No se encuentra el archivo "' + sArchivo + '.' );
Exit;
end;

iAncho := Superficie.w;
iAlto := Superficie.h;

// Fijamos el negro como color transparente
if bTransparente then
SDL_SetColorKey( Superficie, SDL_SRCCOLORKEY, 0 );
end;

Lo que hace el método CargarSuperficie es cargar el bitmap del archivo y le asigna un color transparente si hemos puesto anteriormente la variable bTransparente a True.

También necesitamos un par de métodos para poder convertir en tiempo real un sprite a transparente o para hacerlo opaco. Un caso donde se suele dar es en los juegos de rol. En pantalla el héroe recoge el objeto transparente (por ejemplo una espada) y después la dibujamos en los marcadores sin que sea transparente. Para no tener que crear dos gráficos iguales (uno transparente y otro opaco) he creado estos dos métodos:

procedure TSprite.NoTransparente;
begin
// Lo hacemos opaco
SDL_SetColorKey( Superficie, SDL_SRCCOLORKEY, 255 );
end;

La función SDL_SetColorkey se encarga de decirle a una superficie cual va a ser el color transparente. Viene definida en la librería SDL del siguiente modo:

function SDL_SetColorKey(surface: PSDL_Surface; flag, key: UInt32) : Integer;

Sus parámetros son los siguientes:

Surface: superficie que queremos hacer transparente u opaca.
flag: nivel de transparencia (permite también invertir colores y hacer mezclas).
Key: Color que va a tomarse como transparente (si ponemos 255 es opaco).

procedure TSprite.Transparente;
begin
// Fijamos el negro como color transparente
SDL_SetColorKey( Superficie, SDL_SRCCOLORKEY, 0 );
end;

Yo he elegido el color negro como transparente pero puede ser cualquier otro. Se suele utilizar también mucho el color rosa ya que no es utilizado generalmente en los gráficos.

Y ahora viene el procedimiento más importante de todos:

procedure TSprite.Dibujar;
var
Origen, Destino: TSDL_Rect;
begin
// ¿Está visible el sprite?
if bVisible then
// ¿Tiene subsprites?
if bSubSprites then
begin
Origen.x := iSubX * iAnchoSub;
Origen.y := iSubY * iAltoSub;
Origen.w := iAnchoSub;
Origen.h := iAltoSub;
Destino.x := x;
Destino.y := y;
Destino.w := iAnchoSub;
Destino.h := iAltoSub;

// Dibujamos el subsprite seleccionado
if SDL_BlitSurface( Superficie, @Origen, Pantalla, @Destino ) < 0 then
begin
ShowMessage( 'Error al dibujar el sprite "' + sNombre + '" en pantalla.' );
Exit;
end;
end
else
begin
Origen.x := 0;
Origen.y := 0;
Origen.w := iAncho;
Origen.h := iAlto;
Destino.x := x;
Destino.y := y;
Destino.w := iAncho;
Destino.h := iAlto;

// Lo dibujamos entero
if SDL_BlitSurface( Superficie, @Origen, Pantalla, @Destino ) < 0 then
begin
ShowMessage( 'Error al dibujar el sprite "' + sNombre + '" en pantalla.' );
Exit;
end;
end;
end;

El procedimiento lo he dividido en dos partes principales. Si el sprite tiene subsprites entonces sólo capturo el trozo que me interesa y lo dibujo. En caso contrario dibujo todo el sprite.

Para dibujar un sprite con la librería SDL utilizo la función SDL_BlitSurface. Esta función viene definida en la librería SDL así:

function SDL_BlitSurface(src: PSDL_Surface; srcrect: PSDL_Rect; dst: PSDL_Surface; dstrect: PSDL_Rect): Integer;

Estos son sus parámetros:

Src: superficie origen.
srcrect: coordenadas del rectángulo origen.
dst: superficie destino.
dstrect: coordenadas del rectángulo destino.

PSDL_Rect es realmente un puntero a una estructura de datos (record en Delphi) definida de este modo:

TSDL_Rect = record
x, y: SInt16;
w, h: UInt16;
end;

Si tuviera que buscar una equivalencia entre dibujar con la librería SDL y con el GDI de Windows sería la siguiente:

Canvas = Superficie
TRect = TSDL_Rect

Así de simple y así de fácil.

PASAMOS A LA ACCIÓN

El juego lo vamos crear en una unidad aparte para no estropear nuestras rutinas estándar situadas en Juego.dpr y Juego.pas. Hay que pensar siempre en que si tengo que empezar un juego nuevo, copio estas dos unidades y listo.

Creamos una unidad nueva que se llame UArcade.pas y le vamos a vincular nuestra unidad estándar:

uses SysUtils, UJuego;

También voy a definir una variable global para guardar el gráfico de este avión:


Lo definimos en la sección interface:

var
Avion: TSprite;

El primer procedimiento que vamos a crear para esta unidad va a encargarse de cargar los sprites:

procedure CargarSprites;
begin
Avion := TSprite.Create;
Avion.CargarSuperficie( ExtractFilePath( ParamStr( 0 ) ) + 'avion.bmp' );
end;

Doy por supuesto que el archivo avion.bmp se encuentra al lado de nuestro ejecutable. Este sprite no tiene subsprites, por lo que no hay que hacer nada en especial con el mismo.

También necesitamos otro procedimiento para eliminar los sprites de todo el juego:

procedure DestruirSprites;
begin
Avion.Free;
end;

Y otro para dibujarlos:

procedure DibujarSprites;
begin
Avion.Dibujar;
end;

MODIFICANDO EL BUCLE PRINCIPAL DE JUEGO

Después de toda la parafernalia que he montado vamos a ver los resultados reales. Para ello necesitamos llamar a estos nuevos procedimientos desde el bucle principal del programa situado en la unidad Juego.dpr:

program juego;

uses
Windows,
Dialogs,
SysUtils,
UJuego in 'UJuego.pas',
UArcade in 'UArcade.pas';

{$R *.res}

begin
InicializarSDL;
ModoVideo( 640, 480, 16, True );
Teclado := TTeclado.Create;
Temporizador := TTemporizador.Create;
CargarSprites;

while not bSalir do
begin
Temporizador.Actualizar;

if Temporizador.Activado then
begin
Teclado.Leer;
DibujarSprites;
ActualizarPantalla;
Temporizador.Incrementar;
end
else
Temporizador.Esperar;
end;

DestruirSprites;
Temporizador.Free;
Teclado.Free;
FinalizarSDL;
end.

Si os fijáis en la sección uses he añadido la nueva unidad UArcade.pas.

Todo el bucle está igual que el artículo anterior anterior con la salvedad de que he llamado a los procedimientos CargarSprites, DibujarSprites y DestruirSprites.

Al ejecutar el juego tiene que aparecer el avión en la esquina superior izquierda de la ventana:


En el siguiente artículo veremos como mover el sprite por la pantalla incluso con un fondo. También veremos como moverlo utilizando el teclado y el ratón.


Pruebas realizadas en Delphi 7.

Publicidad