23 octubre 2009

Mi primer videojuego independiente (y 6)

Voy a terminar esta serie de artículos comentando otras dificultades que encontré en el juego como pueden ser el dibujado de piezas con scroll y la colisión con las mismas.

CARGANDO LAS PIEZAS DEL JUEGO

Como dije anteriormente, la pantalla de 640 x 480 la he dividido en piezas de 40 x 40 pixels lo que me dan un total de 16 piezas horizontales y 12 verticales. Estas piezas las guardo en un gran sprite vertical:

El problema está en que como tengo que meterlo en texturas que no superen los 256 de altura lo he partido en texturas de 64 x 256 (me sobra 24 horizontalmente y 16 verticalmente). En cada textura me caben 6 bloques de 40 x 40.

Entonces realizo el proceso de carga de este modo:

procedure CargarSpritesPequepona;
var
Zip: TZipForge;
Stream: TMemoryStream;
Comprimido: PSDL_RWops;
i, yp: Integer;
begin
Piezas := TSprite.Create('Piezas', True, 40, 2120, 40, 40);

// Extraemos las piezas del archivo zip
AbrirZip('graficos.zip', Stream, Zip, Comprimido);
DescomprimirSprite(Stream, Zip, Comprimido, Piezas, True, 40, 40, 'piezas.png');

// Partimos el sprite en texturas y las subimos a la memoria de vídeo
yp := 0;
for i := 1 to 10 do
begin
Piezas.CrearTextura(0, yp, 64, 256, 0, 0, 40, 240);
Inc(yp, 240);
end;
Piezas.LiberarSuperficie;

Zip.Free;
Stream.Free;
end;

Una vez que le mandamos las texturas a OpenGL podemos liberar la superficie que hemos extraído del archivo zip y lo cerramos.

DIBUJANDO LAS PIEZAS CON SCROLL HORIZONTAL

La función de dibujar en pantalla realiza un barrido horizontal y vertical por toda la pantalla y va dibujando las piezas de 40 x 40 y encima los objetos. A continuación explicaré cada parte:

procedure DibujarPantalla;
var
p, i, j, x, y: Integer;
bDibujar: Boolean;
begin
for p := 1 to iNumPan do
begin
for j := 1 to 12 do
begin
for i := 1 to 16 do
begin
x := (i-1)*40+iScroll+(p-1)*640;
y := (j-1)*40;

// Comprobamos que la pieza a dibujar esté dentro de la pantalla

if (x+40 >= 0) and (x <= 640) then
begin
if Pantallas[p].Piezas[i,j] > 0 then
begin
Piezas.x := x;
Piezas.y := y;
Piezas.iSubY := Pantallas[p].Piezas[i, j];
Piezas.Dibujar;
end;

// Caso especial para la llave

if Pantallas[p].Objetos[i,j] = LLAVE then
DibujarAnimacionLlave(x, y );

// Dibujamos el resto de objetos
bDibujar := False;

// Sólo dibujamos los objetos principales
if (Pantallas[p].Objetos[i,j] > LLAVE) and
(Pantallas[p].Objetos[i,j] < RATONDER) then
bDibujar := True;

// Sólo dibujamos los objetos a recoger
if (Pantallas[p].Objetos[i,j] = PANZACOCO) or
(Pantallas[p].Objetos[i,j] = ESCUDO) or
(Pantallas[p].Objetos[i,j] = MUELLE) or
(Pantallas[p].Objetos[i,j] = MUELLE2) or
(Pantallas[p].Objetos[i,j] = RELOJ) then
bDibujar := True;

// El tope de los enemigos no lo sacamos
if Pantallas[p].Objetos[i,j] = TOPE then
bDibujar := False;

if bDibujar then
begin
Objetos.x := x;
Objetos.y := y;
Objetos.iSubY := Pantallas[p].Objetos[i, j];
Objetos.Dibujar;
end;

// Caso especial para la puerta
if Pantallas[p].Objetos[i,j] = PUERTA2 then
DibujarAyuda(x, y);
end;
end;
end;
end;
end;

La primera parte comienzo con un bucle de recorre todas las pantallas y dentro de cada una las piezas horizontal (i) y verticalmente (j):

for p := 1 to iNumPan do
begin
for j := 1 to 12 do
begin
for i := 1 to 16 do begin
x := (i-1)*40+iScroll+(p-1)*640;
y := (j-1)*40;

Aquí la parte más importante es la variable x, que dependiendo de la pantalla p y el desplazamiento de la variable iScroll determinaremos que pantalla dibujar y desde donde comenzamos. No conviene dibujar más de lo normal ya que podríamos ralentizar la librería OpenGL.

Después de convertir las coordenadas de las pantallas virtuales a la pantalla de vídeo entonces compruebo si puedo dibujarlas o no:

if (x+40 >= 0) and (x <= 640) then
begin
if Pantallas[p].Piezas[i,j] > 0 then
begin
Piezas.x := x;
Piezas.y := y;
Piezas.iSubY := Pantallas[p].Piezas[i, j];
Piezas.Dibujar;
end;

Las pantallas las almaceno en este array bidimensional:

Pantallas: array[1..MAX_PAN] of TPantalla;

Donde TPantalla es un registro que almacena todas las piezas:

TPantalla = record
Piezas, Objetos: array[1..16, 1..12] of byte;
end;

Lo que viene a continuación es dibujar los objetos, pero aquí hay que tener cuidado. Los objetos especiales como los topes de los enemigos, las puertas o las llaves tienen su propia rutina de dibujado por lo que las evito mediante la variable bDibujar:

// Caso especial para la llave

if Pantallas[p].Objetos[i,j] = LLAVE then
DibujarAnimacionLlave(x, y );

// Dibujamos el resto de objetos
bDibujar := False;

// Sólo dibujamos los objetos principales
if (Pantallas[p].Objetos[i,j] > LLAVE) and
(Pantallas[p].Objetos[i,j] < RATONDER) then
bDibujar := True;

// Sólo dibujamos los objetos a recoger
if (Pantallas[p].Objetos[i,j] = PANZACOCO) or
(Pantallas[p].Objetos[i,j] = ESCUDO) or
(Pantallas[p].Objetos[i,j] = MUELLE) or
(Pantallas[p].Objetos[i,j] = MUELLE2) or
(Pantallas[p].Objetos[i,j] = RELOJ) then
bDibujar := True;

// El tope de los enemigos no lo sacamos
if Pantallas[p].Objetos[i,j] = TOPE then
bDibujar := False;

if bDibujar then
begin
Objetos.x := x;
Objetos.y := y;
Objetos.iSubY := Pantallas[p].Objetos[i, j];
Objetos.Dibujar;
end;

// Caso especial para la puerta
if Pantallas[p].Objetos[i,j] = PUERTA2 then
DibujarAyuda(x, y);

La variable iScroll la inicializo a cero al comenzar el juego:

iScroll := 0;

Entonces cuando pulso la tecla hacia la derecha incremento el scroll:

// Si el heroe pasa a la mitad derecha de la pantalla
// entonces comprobamos si tiene que hacer scroll:
if Heroe.rx+30 > 320 then
ScrollDerecha;

La variable rx son las coordenadas del heroe en pantalla en números reales. Antes de moverlo he comprobado que si estamos en la primera pantalla y no pasamos de la mitad de la misma no haga scroll.

Entonces el scroll hacia la derecha decrementa la variable iScroll para que las pantallas se vayan desplazando hacia la izquierda mientras nuestro personaje permanece en el centro de la pantalla:

procedure ScrollDerecha;
begin
if Abs(iScroll) < (iNumPan-1)*640 then
Dec(iScroll, Heroe.iVelocidad);
end;

La variable iNumPan es el número de pantallas totales que tiene esta fase. La variable iVelocidad la he fijado en 3 para realizar el scroll aunque a veces de incrementa cuando estamos andando por una plataforma móvil en su misma dirección: Ahora para movernos a hacia la izquierda comprobamos si no estamos en la última pantalla:

if Heroe.rx > 320 then
ScrollIzquierda
else
if iScroll <= 0 then
ScrollIzquierda;

Y entonces muevo todas las pantallas hacia la derecha controlando de que no me pase de la primera pantalla:

procedure ScrollIzquierda;
begin
if (iScroll<0) and (Heroe.Sprite.x+30<320) then
Inc(iScroll, Heroe.iVelocidad);
end;

Básicamente tenemos aquí todo lo que necesitamos para hacer un scroll horizontal y bidireccional. El fallo que cometí fue no haber diseñado un mundo gigante horizontalmente en vez de haberlo partido todo en pantallas. Hubiese sido más fácil así.

CONTROLANDO EL CHOQUE CON PAREDES Y SUELOS

Cuando movemos el personaje por pantalla tenemos que controlar que no choque con los bloques de 40 x 40 que hacen de pared o de suelo. Para ello hice una distinción en el editor para saber los bloques que hacen de suelo y los que hacen de pared:


Con los objetos que hacen de suelo sólo podemos chocar al caer verticalmente pero no horizontalmente. Y con los que hacen de suelo-pared chocamos por todos lados. Lo primero que tenemos que hacer es una función de tal modo que si le pasamos un par de coordenadas nos diga que pieza hay en esa coordenada:

function LeerPieza(x,y: Integer): Byte;
var
xMapa, yMapa, iPantalla: Integer;
begin
LeerCoordenadasMapa(x, y, xMapa, yMapa, iPantalla);
if (xMapa>=1) and (xMapa<=16) and (yMapa>=1) and (yMapa<=12) then
Result := Pantallas[iPantalla].Piezas[xMapa,yMapa]
else
Result := 0;
end;

Pero antes llama al procedimiento LeerCoordenadasMapa que transforma esas coordenadas a pantalla según el scroll:

procedure LeerCoordenadasMapa(x,y: Integer; var xMapa, yMapa, iPantalla: Integer);
begin
iPantalla := x div 640+1;
// ¿La pieza a mirar está en la pantalla de la izquierda?
if x < 0 then
x := (iPantalla-1)*640+x;

// ¿La pieza a mirar está en la pantalla de la derecha?
if x > 640 then
x := x-(iPantalla-1)*640;

xMapa := x div 40+1;
yMapa := y div 40+1;
end;

El peligro de esta función esta en que hay que saber si nos hemos pasado de pantalla y hay que asomarse a la primera columna de la siguiente pantalla.

Una vez sabemos la pieza que hay en un determinado lugar podemos saber si chocamos con ella:

function ChocaConSuelo(x,y: Integer): Boolean;
var
i, iPieza: Integer;
begin
iPieza := LeerPieza(x,y);
Result := False;
for i := 1 to iNumSue do
if iPieza = Suelos[i] then
Result := True;
end;

El array Suelos contiene la numeración de suelos con los que chocamos según lo que hicimos en el editor de pantallas. De la misma forma he operado para comprobar el choque con las paredes, recoger los objetos, etc.

DIBUJANDO LOS NUMEROS DEL MARCADOR

Aunque la librería SDL permite escribir caracteres utilizando las fuentes de Windows, os recomiendo no utilizarlo porque salen las letras en monocromo y pixeladas. Lo mejor es generar los sprites con los caracteres que necesitamos:

Entonces creamos un procedimiento para imprimir dichos números utilizando estos sprites:

procedure DibujarNumero(x,y: Integer; iNumero: Integer);
begin
if ( iNumero <> 99 ) then
Exit;

Numeros.x := x;
Numeros.y := y;
Numeros.iSubX := iNumero div 10;
Numeros.Dibujar;
Inc(Numeros.x,36);
Numeros.iSubX := iNumero mod 10;
Numeros.Dibujar;
end;

Como solo hay que imprimir una puntuación de 0 a 99 entonces cojo el primer dígito y lo divido por 10 y del segundo dígito calculo el resto. También lo podíamos haber realizado convirtiendo el número a string y luego recorriendo la cadena de caracteres e imprimiendo un dígito por cada carácter.

FINALIZANDO

Así como he mostrado estos ejemplos podía haber escrito miles de líneas más sobre enemigos, animaciones, etc., pero necesitaría un tiempo que no tengo. Creo que lo más importante de lo que he hablado es la renderización de polígonos con OpenGL.

Actualmente estoy pasando este motor a 1024 x 768 y 60 fotogramas por segundo y para probarlo estoy haciendo un pequeño juego gratuito que ya os mostraré por aquí cuando lo termine.

Después de todo esto que os he soltado, ¿quién se anima a hacer videojuegos? No diréis que no os lo he puesto a huevo.

Pruebas realizadas en Delphi 7.

16 octubre 2009

Mi primer videojuego independiente (5)

Vamos a terminar de ver el resto de la clase TSprite con el procedimiento más importante del mismo: el encargado de dibujar los polígonos.

DIBUJANDO POLÍGONOS EN PANTALLA

Primero voy a volcar todo el procedimiento de Dibujar y luego explico cada parte:

procedure TSprite.Dibujar;
var
i, j, k: Integer;
rDespX, rDespY: Real; // Desplazamiento X e Y respecto al eje de coordenadas
rEscalaTexX, rEscalaTexY: Real;
rTexX, rTexY: Real; // Coordenadas de la esquina superior izquierda de la textura (0,0)
iSubX2, iSubY2: Integer; // reajustamos los subsprites según la textura
iNumSprHor, iNumSprVer: Integer; // Nº de sprites que caben en la textura horizontal y verticalmente
begin
// Si no tiene texturas no hacemos nada
if iNumTex = 0 then
Exit;

if bTransparente then
begin
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
end
else
glDisable(GL_BLEND);

iSubX2 := 0;
iSubY2 := 0;

// ¿Tiene subsprites?
if bSubSprites then
begin
if iTexSub > 0 then
iTexAct := iTexSub
else
iTexAct := 1;

// Entonces calculamos el ancho, alto y posición de cada subsprite

iNumSprHor := Textura[1].iAnchoTex div iAnchoSub;

// ¿El subsprite se sale de la textura actual?
if (iSubX+1) > iNumSprHor then
begin
if iNumSprHor <> 0 then
begin
// Calculamos en que textura va a caer nuestro subsprite
iTexAct := iSubX div iNumSprHor+1;

// Calculamos en que subsprite cae dentro de esa textura
iSubX2 := iSubX mod iNumSprHor;
end
else
begin
iTexAct := 1;
iSubX2 := 0;
end;
end
else
iSubX2 := iSubX;

// Si el subsprite se pasa de la textura pasamos a la siguiente textura vertical
// Calculamos cuantos sprites caben verticalmente
iNumSprVer := Textura[1].iAltoTex div iAltoSub;

// ¿El subsprite se sale de la textura actual?
if (iSubY+1) > iNumSprVer then
begin
if iNumSprVer <> 0 then
begin
// Calculamos en que textura va a caer nuestro subsprite
iTexAct := iSubY div iNumSprVer+1;

// Calculamos en que subsprite cae dentro de esa textura
iSubY2 := iSubY mod iNumSprVer;
end
else
begin
iTexAct := 1;
iSubY2 := 0;
end
end
else
iSubY2 := iSubY;
end;

// Dibujamos todas las texturas del sprite (o sólo la actual)

for i := 1 to iNumTex do
begin
// ¿Hay que imprimir sólo una textura?
if iTexAct > 0 then
// ¿No es esta textura?
if i <> iTexAct then
// Pasamos a la siguiente textura
Continue;

// Calculamos desde donde comienza la textura
rDespX := x+Textura[i].iIncX;
rDespY := y+Textura[i].iIncY;

// Dibujamos todos los triángulos del objeto que contiene cada sprite

for j := 1 to 2 do
begin
glBindTexture(GL_TEXTURE_2D, Textura[i].ID);
glBegin(GL_TRIANGLES);
for k := 1 to 3 do
begin
// ¿Tiene subsprites?
if bSubsprites then
begin
rTexX := CompToDouble(iSubX2*iAnchoSub)/CompToDouble(Textura[i].iAncho);
rTexY := CompToDouble(iSubY2*iAltoSub)/CompToDouble(Textura[i].iAlto);
rEscalaTexX := CompToDouble(iAnchoSub)/CompToDouble(Textura[i].iAncho);
rEscalaTexY := CompToDouble(iAltoSub)/CompToDouble(Textura[i].iAlto);
glTexCoord2f(rTexX+Textura[i].Triangulo[j].Vertice[k].u*rEscalaTexX,
rTexY+Textura[i].Triangulo[j].Vertice[k].v*rEscalaTexY);
end
else
begin
// Cortamos toda la textura
glTexCoord2f(Textura[i].Triangulo[j].Vertice[k].u,
Textura[i].Triangulo[j].Vertice[k].v);
end;

glVertex3f(Textura[i].Triangulo[j].Vertice[k].x*rEscalaX+rDespX,
480-(Textura[i].Triangulo[j].Vertice[k].y*rEscalaY+rDespY), // la coordenada Y va al revés
Textura[i].Triangulo[j].Vertice[k].z+rProfundidadZ);
end;

glEnd();
end;
end;

rProfundidadZ := rProfundidadZ + 0.01;
glDisable(GL_BLEND);
end;

Lo primero que hacemos es declarar estas variables:

i, j, k: Integer;
rDespX, rDespY: Real;
rEscalaTexX, rEscalaTexY: Real;
rTexX, rTexY: Real;
iSubX2, iSubY2: Integer;
iNumSprHor, iNumSprVer: Integer;

Las variables i, j, k las voy a utilizar para recorrer las texturas, los triángulos y los vértices de este sprite.

Luego tenemos las variables rDesX, rDespY que serán las coordenadas de las esquina superior izquierda de la textura que vamos a capturar dentro del sprite.

Después declaramos rEscalaTexX, rEscalaTexY para calcular las coordenadas de OpenGL dentro de la textura, que como dije anteriormente van de 0 a 1. Haciendo una regla de tres podemos averiguar su proporción.

Como paso intermedio he declarado las variables rTexX, rTexY, iSubX2, iSubY2, iNumSprHor, iNumSprVer para extraer las texturas de cada subsprite.

Al comenzar el procedimiento lo primero que hago es asegurarme de que el sprite tenga texturas y habilitar la transparencia si el sprite tiene esta propiedad:

begin
// Si no tiene texturas no hacemos nada
if iNumTex = 0 then
Exit;

if bTransparente then
begin
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
end
else
glDisable(GL_BLEND);

La transparencia la realizamos con el canal alfa, por lo que es necesario que las texturas seran de 32 bits de color con los componentes (R,G,B,A) siendo A el canal alfa. Es importante utilizar un programa de diseño gráfico que soporte este canal (como GIMP).

En el siguiente bloque comprobamos si el sprite tiene subsprites, por lo que extraemos el trozo que nos interesa según las propiedades iSubX e iSubY del sprite:

// ¿Tiene subsprites?
if bSubSprites then
begin
if iTexSub > 0 then
iTexAct := iTexSub
else
iTexAct := 1;

// Entonces calculamos el ancho, alto y posición de cada subsprite

iNumSprHor := Textura[1].iAnchoTex div iAnchoSub;

// ¿El subsprite se sale de la textura actual?
if (iSubX+1) > iNumSprHor then
begin
if iNumSprHor <> 0 then
begin
// Calculamos en que textura va a caer nuestro subsprite
iTexAct := iSubX div iNumSprHor+1;

// Calculamos en que subsprite cae dentro de esa textura
iSubX2 := iSubX mod iNumSprHor;
end
else
begin
iTexAct := 1;
iSubX2 := 0;
end;
end
else
iSubX2 := iSubX;

// Si el subsprite se pasa de la textura pasamos a la siguiente textura vertical
// Calculamos cuantos sprites caben verticalmente
iNumSprVer := Textura[1].iAltoTex div iAltoSub;

// ¿El subsprite se sale de la textura actual?
if (iSubY+1) > iNumSprVer then
begin
if iNumSprVer <> 0 then
begin
// Calculamos en que textura va a caer nuestro subsprite
iTexAct := iSubY div iNumSprVer+1;

// Calculamos en que subsprite cae dentro de esa textura
iSubY2 := iSubY mod iNumSprVer;
end
else
begin
iTexAct := 1;
iSubY2 := 0;
end
end
else
iSubY2 := iSubY;
end;

Aquí pueden ocurrir dos casos: que los subsprites no superen el tamaño de la textura o que si la superan entonces me encargo de averiguar en que textura cae el subsprite que necesito.

Ahora viene lo mas gordo. En un primer nivel voy recorriendo todas las texturas de las que se compone este sprite:

for i := 1 to iNumTex do
begin
// ¿Hay que imprimir sólo una textura?
if iTexAct > 0 then
// ¿No es esta textura?
if i <> iTexAct then
// Pasamos a la siguiente textura
Continue;

// Calculamos desde donde comienza la textura
rDespX := x+Textura[i].iIncX;
rDespY := y+Textura[i].iIncY;

En el segundo nivel pasamos a recorrer los dos triángulos de la textura (si fuera un juego 3D habría que expandir este bucle según el número de polígonos del objeto 3D):

for j := 1 to 2 do
begin
glBindTexture(GL_TEXTURE_2D, Textura[i].ID);
glBegin(GL_TRIANGLES);

La función glBindTexture de dice a OpenGL que vamos a dibujar texturas 2D y le pasamos el identificador de la textura y luego mediante glBegin(GL_TRIANGLES) le indicamos que vamos a dibujar polígonos de tres vértices.

En el tercer y último nivel recorremos los tres vértices de cada triángulo y dependiendo si tiene subsprites o no recorto la textura a su medida:

for k := 1 to 3 do
begin
// ¿Tiene subsprites?
if bSubsprites then
begin
rTexX := CompToDouble(iSubX2*iAnchoSub)/CompToDouble(Textura[i].iAncho);
rTexY := CompToDouble(iSubY2*iAltoSub)/CompToDouble(Textura[i].iAlto);
rEscalaTexX := CompToDouble(iAnchoSub)/CompToDouble(Textura[i].iAncho);
rEscalaTexY := CompToDouble(iAltoSub)/CompToDouble(Textura[i].iAlto);
glTexCoord2f(rTexX+Textura[i].Triangulo[j].Vertice[k].u*rEscalaTexX,
rTexY+Textura[i].Triangulo[j].Vertice[k].v*rEscalaTexY);
end
else
begin
// Cortamos toda la textura
glTexCoord2f(Textura[i].Triangulo[j].Vertice[k].u,
Textura[i].Triangulo[j].Vertice[k].v);
end;

Y esta función es la que dibuja realmente los polígonos en pantalla:

glVertex3f(Textura[i].Triangulo[j].Vertice[k].x*rEscalaX+rDespX,
480-(Textura[i].Triangulo[j].Vertice[k].y*rEscalaY+rDespY), // la coordenada Y va al revés
Textura[i].Triangulo[j].Vertice[k].z+rProfundidadZ);

Como las coordenadas en OpenGL van en coordenadas ortogonales he tenido que invertir la coordenada Y para ajustarla a coordenadas de pantalla.

Para finalizar le indicamos que hemos terminado de dibujar triángulos y desactivo el efecto GL_BLEND.

end;

glEnd();
end;
end;

rProfundidadZ := rProfundidadZ + 0.01;
glDisable(GL_BLEND);
end;

La variable rProfundidadZ va incrementandose poco a poco para determinar el orden de los sprites en pantalla mediante el buffer de profundidad Z. Este buffer lo inicializo antes de comenzar a dibujar sprites, como veremos más adelante. Esto evita la superposición de polígonos en pantalla (y parpadeos).

OTROS PROCEDIMIENTOS DEL SPRITE

He mantenido el procedimiento DibujarEn por si necesitamos copiar el contenido de una superficie en otra antes de subir la textura a la memoria de vídeo:

procedure TSprite.DibujarEn( SuperficieDestino: PSDL_Surface );
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, SuperficieDestino, @Destino ) < 0 then
begin
ShowMessage( 'Error al dibujar el sprite "' + sNombre + '" en pantalla.' );
bSalir := True;
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, SuperficieDestino, @Destino ) < 0 then
begin
ShowMessage( 'Error al dibujar el sprite "' + sNombre + '" en pantalla.' );
bSalir := True;
Exit;
end;
end;
end;

Luego está el procedimiento Crear que crea una superficie vacía dentro de un sprite que no viene de ningún archivo de disco:

procedure TSprite.Crear;
begin
// Creamos una superficie vacía
Superficie := SDL_CreateRGBSurface( SDL_HWSURFACE, iAncho, iAlto, 32, 0, 0, 0, 0 );
end;

Se puede crear un sprite vacío en el que luego se va escribiendo la puntuación de los marcadores antes de llevarlos a pantalla.

DIBUJANDO SPRITES EN PANTALLA

Con este sistema es como dibujo todos los sprites del juego en pantalla:

procedure DibujarSprites;
begin
rProfundidadZ := 0.0;

case iEscena of
ESC_LOGOTIPO: DibujarSpritesLogotipo;
ESC_PRESENTA: DibujarSpritesPresentacion;
ESC_OPCIONES: DibujarSpritesOpciones;
ESC_MAPA: DibujarSpritesMapa;
ESC_JUEGO: DibujarSpritesJuego;
ESC_GAMEOVER: DibujarSpritesGameOver;
ESC_CASTILLO: DibujarCastillo;
ESC_PEQUEPONA: DibujarPequepona;
ESC_INTRO: DibujarIntro;
end;
end;

Inicializo primero el buffer de profundidad y luego determino que tengo de dibujar. Para hacer el juego lo más modular posible lo he dividido todo en escenas, de modo que llamo a cada escena según corresponda. Este sería el procedimiento dentro del juego:

procedure DibujarSpritesJuego;
begin
Fondo.Dibujar;
DibujarPantalla;
DibujarPlataformas;
DibujarEnemigos;
Heroe.Dibujar;
DibujarFresa;
DibujarVida;
DibujarLlave;
DibujarCoco;
Estrellas;

if bPausa then
DibujarPausa;
end;

De este modo vamos descomponiendo el dibujado de sprites en forma de árbol respetando la prioridad de cada sprite.

DIBUJANDO SUPERSPRITES

¿Cómo podemos dibujar toda la pantalla mediante texturas de 256x256? Pues troceándola en texturas de potencia de 2. Por ejemplo, este sería el proceso de carga de la pantalla de presentación:

procedure CargarSpritesPresentacion;
var
Zip: TZipForge;
Stream: TMemoryStream;
Comprimido: PSDL_RWops;
begin
// Creamos los sprites que van a cargar los datos comprimidos

Presentacion := TSprite.Create('Presentacion', False, 640, 480, 0, 0);

AbrirZip('gfcs\presentacion.gfcs', Stream, Zip, Comprimido);
DescomprimirSprite(Stream, Zip, Comprimido, Presentacion, False, 0, 0, 'presentacion.png');

CrearPantalla640x480(Presentacion);

Zip.Free;
Stream.Free;
end;

Por si acaso había que hacer más pantallas de 640 x 480 me hice un prodecimiento general que partía toda la pantalla y las enviaba a la memoria de vídeo:

procedure CrearPantalla640x480(Sprite: TSprite);
begin
// Fila de arriba
Sprite.CrearTextura( 0, 0, 256, 256, 0, 0, 256, 256);
Sprite.CrearTextura(256, 0, 256, 256, 256, 0, 256, 256);
Sprite.CrearTextura(512, 0, 128, 256, 512, 0, 128, 256);

// Fila central
Sprite.CrearTextura( 0, 256, 256, 128, 0, 256, 256, 256);
Sprite.CrearTextura(256, 256, 256, 128, 256, 256, 256, 256);
Sprite.CrearTextura(512, 256, 128, 128, 512, 256, 128, 256);

// Penúltima fila de abajo
Sprite.CrearTextura( 0, 384, 256, 64, 0, 384, 256, 64);
Sprite.CrearTextura(256, 384, 256, 64, 256, 384, 256, 64);
Sprite.CrearTextura(512, 384, 128, 64, 512, 384, 256, 64);

// Última fila de abajo
Sprite.CrearTextura( 0, 448, 256, 32, 0, 448, 256, 32);
Sprite.CrearTextura(256, 448, 256, 32, 256, 448, 256, 32);
Sprite.CrearTextura(512, 448, 128, 32, 512, 448, 256, 32);

Sprite.LiberarSuperficie;
end;

Esa sería la pantalla troceada:

Después para dibujarla sólo tengo que hacer esto:

Presentacion.Dibujar;

Y ya se encargan mis rutinas de dibujar las 12 texturas del sprite. Es lo que yo llamo un supersprite. En el siguiente artículo comentaré como resolví el tema del scroll horizontal así como otros problemas similares (el héroe, los enemigos, etc.).

Pruebas realizadas con Delphi 7.

09 octubre 2009

Mi primer videojuego independiente (4)

Después de preparar toda la artillería que necesitamos para dibujar sprites en OpenGL vamos a implementar por fin la clase TSprite. Vamos a ver como se carga la textura de un sprite desde un archivo comprimido con zip.


CARGANDO SPRITES

Esta clase es similar a la que hice en cursos anteriores pero ahora comento lo que he añadido nuevo respecto a OpenGL:

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;
rEscalaX, rEscalaY: Real; // Escala de ampliación X e Y

Textura: array[1..MAX_TEXTURAS] of TTextura; // Texturas disponibles para este sprite
iNumTex: Integer; // Nº de texturas
iTexAct: Integer; // Si es mayor de cero sólo se imprime esta textura (sin subsprites)
iTexSub: Integer; // Si es mayor de cero sólo se imprime esta textura (con subsprites)

constructor Create(sNombreSprite: String; bSubsprites: Boolean;
iAncho, iAlto, iAnchoSub, iAltoSub: Integer);
destructor Destroy; override;
procedure CargarSuperficieComprimida(Comprimido: PSDL_RWops);
procedure Dibujar;
procedure DibujarEn(SuperficieDestino: PSDL_Surface);
procedure Crear;
procedure CrearTextura(xInicial, yInicial, iAncho, iAlto,
iIncrementoX, iIncrementoY, iAnchoTex, iAltoTex: Integer );
procedure LiberarSuperficie;
end;

Las primeras variables contienen un nombre único que le asigno cada sprite, sus dimensiones y las coordenadas en pantalla en números enteros y en números reales:

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

Después tenemos tres variables booleanas para saber si el sprite esta visible, si es transparente o tiene subsprites:

bVisible, bTransparente: Boolean;
bSubsprites: Boolean; // ¿Tiene subsprites?

¿Qué es un subsprite? Pues un sprite dentro de otro sprite:

Como la clase TSprite la utilizo para todo, lo mismo nos toca dibujar toda la pantalla con un solo sprite compuesto de muchas texturas de 256 x256 (lo que yo llamo un supersprite) que lo mismo sólo tenemos que dibujar una parte del sprite (subsprite), como en el caso de la tortuga que aprovecho la misma textura para poner todas las posiciones.

En el caso de que nuestro sprite tenga subsprites entonces debemos darle las dimensiones de los mismos:

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

Las variables iSubX e iSubY las utilizo para saber que subsprite voy a imprimir antes de llamar al procedimiento Dibujar. Después tenemos la superficie donde vamos a cargar la textura del sprite antes de subirla a la memoria de vídeo:

Superficie: PSDL_Surface;
rEscalaX, rEscalaY: Real; // Escala de ampliación X e Y

Las variables rEscalaX y rEscalaY las utilizo para aumentar o disminuir el tamaño del sprite. Esto lo hice para aumentar el tamaño de los botones del menú principal:

Aquí añado algunas novedades pertenecientes a OpenGL:

Textura: array[1..MAX_TEXTURAS] of TTextura; // Texturas disponibles para este sprite
iNumTex: Integer; // Nº de texturas
iTexAct: Integer; // Si es mayor de cero sólo se imprime esta textura (sin subsprites)
iTexSub: Integer; // Si es mayor de cero sólo se imprime esta textura (con subsprites)

Creo un array de texturas donde le pongo por ejemplo un máximo de 100 texturas por sprite:

const
MAX_TEXTURAS = 100; // Nº máximo de texturas por sprite

Y las variables iTexAct y iTexSub las utilizo para sprites que son más grandes de 256 x 256 cuando tengo que seleccionar que textura voy a imprimir. Ya lo veremos en el siguiente artículo cuando muestre las rutinas de impresión de polígonos.

El constructor del sprite inicializa todas estas variables así como las que le pasamos como parámetro para crear el sprite:

constructor TSprite.Create(sNombreSprite: String; bSubsprites: Boolean;
iAncho, iAlto, iAnchoSub, iAltoSub: Integer);
var
i: Integer;
begin
sNombre := sNombreSprite;
Superficie := nil;
bVisible := True;
bTransparente := True;
rx := 0;
ry := 0;
x := 0;
y := 0;
Self.iAncho := iAncho;
Self.iAlto := iAlto;
Self.bSubsprites := bSubSprites;
iNumSubX := 0;
iNumSubY := 0;
Self.iAnchoSub := iAnchoSub;
Self.iAltoSub := iAltoSub;
iSubX := 0;
iSubY := 0;
rEscalaX := 1;
rEscalaY := 1;
iNumTex := 0;
rProfundidadZ := 0;
iTexSub := 0;

// Inicializamos el array de texturas
for i := 1 to MAX_TEXTURAS do
Textura[i] := nil;
end;

Por ejemplo, para cargar el fondo de pantalla creo este sprite:

Fondo := TSprite.Create('Fondo', False, 640, 480, 0, 0);

En cambio, para el pájaro violeta necesito crear subsprites:

PajaroVioleta := TSprite.Create('PajaroVioleta', True, 180, 56, 45, 28);

O puede haber sprites como la pausa donde todo es una sola textura:

Pausa := TSprite.Create('Pausa', False, 303, 292, 0, 0);

El destructor de esta clase debe eliminar todas las texturas y objetos que haya creados en memoria:

destructor TSprite.Destroy;
var
i: Integer;
begin
LiberarSuperficie;

// Destruimos las texturas
for i := 1 to MAX_TEXTURAS do
if Textura[i] <> nil then
FreeAndNil(Textura[i]);

iNumTex := 0;
inherited;
end;

El procedimiento LiberarSuperficie elimina la superficie de OpenGL que una vez que subimos a memoria de vídeo ya no necesitamos:

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

CARGANDO IMÁGENES COMPRIMIDAS CON ZIP

Aquí entramos en una parte importante de esta clase. Para proteger nuestros gráficos vamos a cargar las texturas PNG que están comprimidas en un archico ZIP con contraseña. De este modo evitamos que otra gente utilice nuestros gráficos para hacer otros juegos cutres. Esta sería la rutina de carga:

procedure TSprite.CargarSuperficieComprimida(Comprimido: PSDL_RWops);
begin
if Superficie <> nil then
begin
SDL_FreeSurface(Superficie);
Superficie := nil;
end;

Superficie := IMG_Load_RW(Comprimido, 1);

if Superficie = nil then
begin
ShowMessage('Error al cargar el archivo comprimido ' + sNombre + '.');
Exit;
end;

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

// Fijamos el negro como color transparente
if bTransparente then
SDL_SetColorKey(Superficie, SDL_SRCCOLORKEY or SDL_RLEACCEL,
SDL_MapRGBA(Pantalla.format, 255, 0, 255, 1));
end;

El parámetro Comprimido es del tipo PSDL_RWops, un tipo especial definido en la librería SDL para cargar gráficos desde un buffer de memoria. Luego os mostraré como lo creo al descomprimir el archivo ZIP.

Al principio del procedimiento compruebo si ya la hemos cargado anteriormente para liberarla:

if Superficie <> nil then
begin
SDL_FreeSurface( Superficie );
Superficie := nil;
end;

A continuación cargo la superficie y almaceno el ancho y alto del sprite:

Superficie := IMG_Load_RW( Comprimido, 1 );

if Superficie = nil then
begin
ShowMessage( 'Error al cargar el archivo comprimido ' + sNombre + '.' );
Exit;
end;

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

Y en el caso de que sea transparente le asigno como color transparente el rosa o el canal alfa:

// Fijamos el negro como color transparente
if bTransparente then
SDL_SetColorKey( Superficie, SDL_SRCCOLORKEY or SDL_RLEACCEL,
SDL_MapRGBA( Pantalla.format, 255, 0, 255, 1 ) );

Para cargar los sprites hago esto:

var
Zip: TZipForge;
Stream: TMemoryStream;
Comprimido: PSDL_RWops;
PajaroVioleta: TSprite;
begin
AbrirZip('graficos.zip', Stream, Zip, Comprimido);
DescomprimirSprite(Stream, Zip, Comprimido, PajaroVioleta, True, 45, 28,
'pajaro_violeta.png');
...

Para ello creé un procedimiento para abrir archivos Zip utilizando el componente ZipForge:

procedure AbrirZip(sArchivo: string; var Stream: TMemoryStream;
var Zip: TZipForge; var Comprimido: PSDL_RWops);
begin
Stream := TMemoryStream.Create;
Zip := TZipForge.Create(nil);
Zip.Password := 'miclave';
Zip.FileName := sRuta+sArchivo;
Zip.TempDir := sRuta;
zip.BaseDir := sruta;
Zip.OpenArchive(fmOpenRead);
Comprimido := nil;
end;

Y una vez abierto voy descomprimiendo texturas:

procedure DescomprimirSprite(Stream: TMemoryStream; Zip: TZipForge;
Comprimido: PSDL_RWops; Sprite: TSprite; bSubSprites: Boolean;
iAncho, iAlto: Integer; sNombre: string);
begin
Stream.Clear;
Zip.ExtractToStream(sNombre, Stream);
Comprimido := SDL_RWFromMem(Stream.Memory, Stream.Size);
Sprite.CargarSuperficieComprimida(Comprimido);
Sprite.bSubsprites := bSubSprites;

if bSubSprites then
begin
Sprite.iAnchoSub := iAncho;
Sprite.iAltoSub := iAlto;
end;
end;

Este procedimiento va extrayendo del archivo zip cada textura y la carga en la superficie del sprite. Entonces después de descomprimir la textura del archivo zip me la llevo de la superficie SDL a la memoria de vídeo:

var
Zip: TZipForge;
Stream: TMemoryStream;
Comprimido: PSDL_RWops;
PajaroVioleta: TSprite;
begin
AbrirZip('graficos.zip', Stream, Zip, Comprimido);
DescomprimirSprite(Stream, Zip, Comprimido, PajaroVioleta, True, 45, 28,
'pajaro_violeta.png');
Sprite.CrearTextura(0, 0, 256, 64, 0, 0, 180, 56);
Sprite.LiberarSuperficie;
Zip.Free;
Stream.Free;
end;

El procedimiento de crear la textura sube mediante OpenGL la textura a la memoria de vídeo:

procedure TSprite.CrearTextura(xInicial, yInicial, iAncho, iAlto,
iIncrementoX, iIncrementoY, iAnchoTex, iAltoTex: Integer);
begin
// Creamos la textura a partir de esta imagen
if iNumTex < MAX_TEXTURAS then
begin
Inc(iNumTex);
Textura[iNumTex] := TTextura.Create(Superficie, xInicial, yInicial,
iAncho, iAlto, iIncrementoX, iIncrementoY, iAnchoTex, iAltoTex,
iAnchoSub, iAltoSub, bSubsprites);
Textura[iNumTex].sNombre := sNombre;
end;
end;

Después la podemos liberar así como el archivo ZIP y el stream que hemos utilizado como intermediario. No os podéis imaginar las explosiones que me dio Delphi antes de conseguirlo. En el próximo artículo veremos como dibujar el sprite en pantalla según si es un sprite normal, un subsprite o un supersprite.

Pruebas realizadas en Delphi 7.

02 octubre 2009

Mi primer videojuego independiente (3)


Después de poner en marcha la librería OpenGL vamos a ver todo lo que necesitamos antes de implementar la clase TSprite. Necesitamos preparar nuestra librería UJuego.pas con la capacidad de dibujar polígonos.

LOS VERTICES

Dibujar sprites en con aceleración en 3D no suele ser tan fácil como copiar una imagen de un sitio a otro. En OpenGL debemos dibujar polígonos proporcionando tanto las coordenadas de sus vértices como las de sus texturas.

Los polígonos pueden ser cuadrados o triángulares, aunque cada GPU divide los cuadrados en triángulos ya que se procesan más rápidos internamente. Como a mí siempre me ha gustado hacer las cosas al nivel más bajo posible, entonces lo que hago es utilizar dos triángulos por cada sprite:

A cada triángulo hay que darle sus coordenadas en el sentido de las agujas del reloj, tal como elegimos anteriormente en las opciones de inicialización de OpenGL. Por lo tanto, por cada triángulo tenemos que proporcionar los tres vértices:

En el ejemplo de la imagen superior, suponiendo que nuestro sprite este situado en la esquina superior izquierda de la pantalla y que tenga un algo y alto de 100 pixels entonces las coordenadas del primer triángulo serían:

V1(x= 0, y=100, z=0)
V2(x= 0, y= 0, z=0)
V3(x=100, y= 0, z=0)

Supuestamente nunca llegaremos a utilizar la coordenada de profundidad Z porque estamos haciendo un juego 2D. Luego veremos más adelante como la utilizo mediante el Z-Buffer para ordenar la impresión de sprites. Y estas serían las coordenadas para el segundo triángulo:

V1(x= 0, y=100, z=0)
V2(x=100, y= 0, z=0)
V3(x=100, y=100, z=0)

Pero es que aquí no acaba la cosa. Sólo le hemos dicho donde queremos dibujar el polígono en pantalla pero es que además hay que mapear una textura encima del triángulo. Es como si a cada triángulo le supiésemos una pegatina encima (la textura):

Por ello, cada vértice tiene dos sistemas de coordenadas, una para dibujarlo (x,y,z) y otras para la textura (u, v). La coordenada u es la horizontal y v la vertical. Equivalen a unas coordenadas x, y dentro de la textura. Estas coordenadas vienen en números reales y van desde 0 a 1. Para nuestro sprite estas serían las coordendas u,v de la textura:

V1(u=0, v=1)
V2(u=0, v=0)
V3(u=1, v=0)

Y para el segundo polígono:

V1(u=0,v=1)
V2(u=1,v=0)
V3(u=1,v=1)

Es prácticamente lo mismo que las coordenadas x, y pero a escala 0 a 1. Si hiciésemos un juego en 3D ya se encargarían los programas como 3D Studio de darnos las coordenadas (x,y,z,u,v) por cada vértice.

Entonces, en el momento justo donde llevamos los polígonos a pantalla debemos primero darle a OpenGL las coordenadas de la textura y después las coordenadas del triángulo. Ya lo veremos más adelante cuando lancemos los polígonos a pantalla.

Pues bien, todo este rollo que os he soltado es para definir la clase TVertice que contiene las coordenadas en pantalla y en la textura:

TVertice = class
x, y, z: GLfloat; // Coordenadas
u, v: GLfloat; // Coordenadas de la textura

constructor Create; overload; // constructor por defecto
constructor Create(x, y, z, u, v: GLfloat); overload; // constructor con parámetros
end;

He definido un par de constructores. El básico:

constructor TVertice.Create;
begin
x := 0;
y := 0;
z := 0;
u := 0;
v := 0;
end;

Y otro por si queremos crear el vértice y pasarle directamente las coordenadas:

constructor TVertice.Create(x, y, z, u, v: GLfloat);
begin
Self.x := x;
Self.y := y;
Self.z := z;
Self.u := u;
Self.v := v;
end;

El tipo GLfloat viene definido por defecto por OpenGL aunque también podemos utilizar los tipos Real o Double. Lo mejor es utilizar todo lo posible los tipos de datos de OpenGL por si hay que pasar nuestro motor a otros lenguajes como C/C++, C#, Python o Ruby, ya que tanto la librería SDL como la OpenGL son multiplataforma y multilenguaje.

LOS TRIÁNGULOS

Lo siguiente es definir la clase TTriangulo que va a contener los 3 vértices del polígono y un identificador para la textura:

TTriangulo = class
Vertice: array[1..3] of TVertice; // Los 3 vertices del triángulo
IdTextura: GLuint; // Nº de textura asignada a este triángulo

constructor Create; overload; // constructor por defecto
constructor Create(x1, y1, z1, u1, v1,
x2, y2, z2, u2, v2,
x3, y3, z3, u3, v3: GLFloat ); overload; // constructor con parámetros
destructor Destroy; override;
end;

Antes de comenzar a renderizar polígonos en pantalla debemos subir todas las texturas a la memoria de vídeo. Cada vez que subimos una textura, la librería OpenGL nos proporciona un identificador de la textura (1, 2, 3, …). Necesitamos guardarlo en IdTextura para más adelante.

Para esta clase he creado dos constructores y un destructor:

constructor TTriangulo.Create;
var i: Integer;
begin
// Creamos los tres vértices
for i := 1 to 3 do
Vertice[i] := TVertice.Create;
end;

constructor TTriangulo.Create(x1, y1, z1, u1, v1,
x2, y2, z2, u2, v2, x3,
y3, z3, u3, v3: GLFloat);
begin
// Creamos los tres vértices según sus parámetros
Vertice[1] := TVertice.Create(x1, y1, z1, u1, v1);
Vertice[2] := TVertice.Create(x2, y2, z2, u2, v2);
Vertice[3] := TVertice.Create(x3, y3, z3, u3, v3);
end;

destructor TTriangulo.Destroy;
begin
Vertice[1].Free;
Vertice[2].Free;
Vertice[3].Free;
end;

LAS TEXTURAS

La clase encargada de almacenar los datos de la textura para cada par de triángulos es la siguiente:

TTextura = class
sNombre: string; // Nombre del sprite
iAncho, iAlto: Integer; // Ancho y alto de la textura
iIncX, iIncY: Integer; // Incremento de X, Y respecto al sprite
iAnchoTex, iAltoTex: Integer; // Alto y ancho de la textura que vamos a recortar

Triangulo: array[1..2] of TTriangulo; // Los dos triángulos del sprites
ID: GLuint; // Identificador de la textura

constructor Create(Origen: PSDL_Surface; xInicial, yInicial, iAncho, iAlto,
iIncrementoX, iIncrementoY, iAnchoTex, iAltoTex, iAnchoSub,
iAltoSub: Integer; bSubsprites: Boolean); overload;
destructor Destroy; override;
end;

Definimos las siguientes variables:

sNombre: nombre del sprite asociado a esta textura.

iAncho, iAlto: Alto y ancho de la textura.

iIncX, iIncY: Son las coordenadas desde donde capturamos la textura (por efecto en la esquina superior izquierda (0,0)). Cuando un sprite es más grande de 256x256 necesito crear varias texturas que simulen un supersprite. Por ello necesito estas variables para saber cual es la esquina superior izquierda donde comienza la textura dentro del sprite.

iAnchoTex, iAltoTex: Ancho y largo real del sprite dentro de la textura. Como en OpenGL sólo podemos crear texturas con un ancho y alto que sea potencia de dos entonces guardo también en estas variables el ancho y largo real del sprite. Puede ser que el sprite sea de 96x78 pero necesite crear para el mismo una textura de 128x128.

Después tenemos dos triángulos definidos dentro de la textura y su identificador:

Triangulo: array[1..2] of TTriangulo; // Los dos triángulos del sprites
ID: GLuint; // Identificador de la textura

El constructor que he creado para la textura es algo complejo, porque no sólo crea los triángulos que vamos a utilizar sino que también carga la textura desde la superficie que le pasamos como parámetro. En vez de cargar la textura desde un archivo PNG la cargo desde un archivo ZIP comprimido. La razón no es otra que la de proteger nuestros gráficos PNG para que nadie pueda manipularlos.

Lo que hago es comprimir todos los gráficos que necesito en archivos ZIP con contraseña de manera que sólo el programador sabe la clave de los mismos. De este modo mantenemos la propiedad intelectual del trabajo realizado por el diseñador gráfico, evitando a sí que cualquier pardillo coja nuestros gráficos y haga un juego web con flash en cuatro días llevándose el mérito.

Es algo más complejo de lo normal pero merece la pena. Veamos primero todo el procedimiento y luego analicemos sus partes:

constructor TTextura.Create(Origen: PSDL_Surface; xInicial, yInicial, iAncho,
iAlto, iIncrementoX, iIncrementoY, iAnchoTex, iAltoTex,
iAnchoSub, iAltoSub: Integer; bSubsprites: Boolean);
var
Temp: PSDL_Surface;
Org, Des: TSDL_Rect;
iLongitudX, iLongitudY: Integer;
begin
// Guardamos el ancho y alto de la textura que vamos a crear
Self.iAncho := iAncho;
Self.iAlto := iAlto;
iIncX := iIncrementoX;
iIncY := iIncrementoY;
Self.iAnchoTex := iAnchoTex;
Self.iAltoTex := iAltoTex;

// Definimos las coordenadas de los dos triángulos de la textura
if bSubsprites then
begin
iLongitudX := iAnchoSub;
iLongitudY := iAltoSub;
end
else
begin
iLongitudX := iAncho;
iLongitudY := iAlto;
end;

Triangulo[1] := TTriangulo.Create( 0.0, iLongitudY, 0.0, 0.0, 1.0,
0.0, 0.0, 0.0, 0.0, 0.0,
iLongitudX, 0.0, 0.0, 1.0, 0.0);

Triangulo[2] := TTriangulo.Create( 0.0, iLongitudY, 0.0, 0.0, 1.0,
iLongitudX, 0.0, 0.0, 1.0, 0.0,
iLongitudX, iLongitudY, 0.0, 1.0, 1.0);

// Creamos una nueva superficie con canal alpha
if SDL_BYTEORDER = SDL_BIG_ENDIAN then
Temp := SDL_CreateRGBSurface(SDL_SRCALPHA, iAncho, iAlto, 32, $FF000000,
$00FF0000, $0000FF00, $000000FF)
else
Temp := SDL_CreateRGBSurface(SDL_SRCALPHA, iAncho, iAlto, 32, $000000FF,
$0000FF00, $00FF0000, $FF000000);

// Activamos el canal alpha en la superficie origen antes de copiarla
SDL_SetAlpha(Origen, 0, 0);

// Copiamos la superficie origen a la superficie temporal
Org.x := xInicial;
Org.y := yInicial;

// Evitamos que copie un trozo más ancho que el de la textura
if Origen.w > iAncho then
Org.w := iAncho
else
Org.w := Origen.w;

// Evitamos que copie un trozo más ancho que el de la textura
if Origen.h > iAlto then
Org.h := iAlto
else
Org.h := Origen.h;

Des.x := 0;
Des.y := 0;
Des.w := Org.w;
Des.h := Org.h;
SDL_BlitSurface(Origen, @Org, Temp, @Des);

// Le pedimos a OpenGL que nos asigne un identificador de la textura
glGenTextures(1, ID);

// Enviamos la textura a la superficie de vídeo con OpenGL
glBindTexture(GL_TEXTURE_2D, ID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // Filtro de
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); // suavizado
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP); // Para que no se
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP); // repita la textura
glTexImage2D(GL_TEXTURE_2D, 0, 4, iAncho, iAlto, 0, GL_RGBA,
GL_UNSIGNED_BYTE, Temp.pixels);

// Liberamos la superficie temporal
SDL_FreeSurface(Temp);
end;

En primer lugar defino una supercifie, dos rectángulos y las longitudes horizontales y verticales de la textura:

var
Temp: PSDL_Surface;
Org, Des: TSDL_Rect;
iLongitudX, iLongitudY: Integer;

Luego guardamos todo lo que nos pasan como parámetro en las variables de la clase:

Self.iAncho := iAncho;
Self.iAlto := iAlto;
iIncX := iIncrementoX;
iIncY := iIncrementoY;
Self.iAnchoTex := iAnchoTex;
Self.iAltoTex := iAltoTex;

Determinamos el ancho y alto de la textura según si vamos a dibujar todo el sprite o una parte del mismo (subsprites):

if bSubsprites then
begin
iLongitudX := iAnchoSub; // ancho subsprite
iLongitudY := iAltoSub; // alto subsprite
end
else
begin
iLongitudX := iAncho; // ancho sprite
iLongitudY := iAlto; // alto sprite
end;

Ahora creamos los dos tríangulos dentro de nuestra textura:

Triangulo[1] := TTriangulo.Create( 0.0, iLongitudY, 0.0, 0.0, 1.0,
0.0, 0.0, 0.0, 0.0, 0.0,
iLongitudX, 0.0, 0.0, 1.0, 0.0);

Triangulo[2] := TTriangulo.Create( 0.0, iLongitudY, 0.0, 0.0, 1.0,
iLongitudX, 0.0, 0.0, 1.0, 0.0,
iLongitudX, iLongitudY, 0.0, 1.0, 1.0);

Mientras las coordenadas de los vértices vayan en el sentido de las agujas del reloj, da igual su orden. Creamos una nueva superficie activando el canal alfa:

if SDL_BYTEORDER = SDL_BIG_ENDIAN then
Temp := SDL_CreateRGBSurface(SDL_SRCALPHA, iAncho, iAlto, 32, $FF000000,
$00FF0000, $0000FF00, $000000FF)
else
Temp := SDL_CreateRGBSurface(SDL_SRCALPHA, iAncho, iAlto, 32, $000000FF,
$0000FF00, $00FF0000, $FF000000);

SDL_SetAlpha(Origen, 0, 0);

Después copiamos a la superficie creada el trozo de sprite que vamos a aplicar como textura:

Org.x := xInicial;
Org.y := yInicial;

// Evitamos que copie un trozo más ancho que el de la textura
if Origen.w > iAncho then
Org.w := iAncho
else
Org.w := Origen.w;

// Evitamos que copie un trozo más ancho que el de la textura
if Origen.h > iAlto then
Org.h := iAlto
else
Org.h := Origen.h;

Des.x := 0;
Des.y := 0;
Des.w := Org.w;
Des.h := Org.h;
SDL_BlitSurface(Origen, @Org, Temp, @Des);

Por último, subimos la textura a la memoria de vídeo pidiéndole a OpenGL que nos proporcione un identificador para la misma y liberamos la superficie temporal:

glGenTextures(1, ID);

// Enviamos la textura a la superficie de vídeo con OpenGL
glBindTexture(GL_TEXTURE_2D, ID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // Filtro de
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); // suavizado
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP); // Para que no se
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP); // repita la textura
glTexImage2D(GL_TEXTURE_2D, 0, 4, iAncho, iAlto, 0, GL_RGBA,
GL_UNSIGNED_BYTE, Temp.pixels);

// Liberamos la superficie temporal
SDL_FreeSurface(Temp);

Sólo queda eliminar la textura en su destructor para no dejar nada en la memoria de vídeo:

destructor TTextura.Destroy;
var Textura: GLuint;
begin
Textura := ID;
glDeleteTextures(ID,Textura);
Triangulo[2].Free;
Triangulo[1].Free;
inherited;
end;

En el próximo artículo entraremos con la clase TSprite y veremos como se carga desde un archivo ZIP nuestras texturas en archivos PNG utilizando el componente ZipForge.

Pruebas realizadas en Delphi 7.

25 septiembre 2009

Mi primer videojuego independiente (2)



Una vez que hemos visto por encima las dificultades más importantes que se me dieron con el editor, vamos a pasar a ver las partes más importantes del juego. Sobre todo voy a centrarme en el apartado gráfico, ya que lo que se refiere a joystick, sonido, etc. no cambia casi nada respecto lo que escribí en los anteriores artículos referentes a la SDL.

EL NÚCLEO DEL JUEGO

El núcleo principal del juego no difiere demasiado respecto a lo escribí anteriormente. Como no se utiliza en casi nada las librerías de Delphi lo que hay que hacer es crear un nuevo proyecto, eliminar el formulario principal y escribir este código directamente en el archivo DPR:

begin
InicializarSDL('Pequepon');
CargarOpciones;
ModoVideo(640, 480, 32, bPantallaCompleta);
Teclado := TTeclado.Create;
Temporizador := TTemporizador.Create;
Joystick := TJoystick.Create;
Raton := TRaton.Create;
ControlSonido := TControlSonido.Create;
InicializarJuego;

while not bSalir do
begin
Temporizador.Actualizar;

if Temporizador.Activado then
begin
Teclado.Leer;
Joystick.Leer;
Raton.Leer;
ControlarEventos;
ComenzarRender;
DibujarSprites;
FinalizarRender;
Temporizador.Incrementar;
end
else
Temporizador.Esperar;
end;

Demo.Free;
DestruirSprites;
FinalizarJuego;
Raton.Free;
Joystick.Free;
ControlSonido.Free;
Temporizador.Free;
Teclado.Free;
FinalizarSDL;
end.

Podemos dividir el núcleo en tres bloques principales:

Inicialización: cambiamos el modo de vídeo y creamos los objetos que necesitamos a lo largo del juego:

InicializarSDL('Pequepon');
CargarOpciones;
ModoVideo(640, 480, 32, bPantallaCompleta);
Teclado := TTeclado.Create;
Temporizador := TTemporizador.Create;
Joystick := TJoystick.Create;
Raton := TRaton.Create;
ControlSonido := TControlSonido.Create;
InicializarJuego;

La variable booleana bPantallaCompleta recoge de las opciones si estamos en modo ventana o en pantalla completa.

Bucle infinito principal: Por este bucle pasará todo el juego 40 veces por segundo. No terminará hasta que la variable booleana bSalir este activada:

while not bSalir do
begin
Temporizador.Actualizar;

if Temporizador.Activado then
begin
Teclado.Leer;
Joystick.Leer;
Raton.Leer;
ControlarEventos;
ComenzarRender;
DibujarSprites;
FinalizarRender;
Temporizador.Incrementar;
end
else
Temporizador.Esperar;
end;

Finalización y destrucción de objetos: Nos deshacemos de todos los objetos que hay en memoria y finalizamos el modo de video para volver al escritorio de Windows:

Demo.Free;
DestruirSprites;
FinalizarJuego;
Raton.Free;
Joystick.Free;
ControlSonido.Free;
Temporizador.Free;
Teclado.Free;
FinalizarSDL;

Comencemos viendo las rutinas de inicialización más importantes.

INICIALIZANDO LA LIBRERÍA SDL

Todas las rutinas de mi motor 2D las introduje dentro de una unidad llamada UJuego.pas. De este modo, si tenemos que hacer otro juego sólo hay que importar esta unidad y tenemos medio trabajo hecho.

Veamos como arrancar la librería SDL con el procedimiento InicializarSDL:

procedure InicializarSDL(sTitulo: String);
var i: Integer;
begin
// Inicializamos la librería SDL
if SDL_Init(SDL_INIT_VIDEO or SDL_DOUBLEBUF or SDL_INIT_JOYSTICK) < 0 then
begin
ShowMessage('Error al inicializar la librería SDL.');
SDL_Quit;
bSalir := True;
Exit;
end;

SDL_WM_SetCaption(PChar(sTitulo), nil);
bSalir := False;
sRuta := ExtractFilePath(ParamStr(0));

// Inicializamos los sprites
iNumSpr := 0;
for i := 1 to MAX_SPRITES do
Sprite[i] := nil;
end;

Probamos a cambiar el modo de vídeo activando el doble buffer y el joystick. Si funciona entonces le ponemos el título a la ventana (por si ejecutamos el juego en modo ventana), memorizamos en la variable sRuta donde esta nuestro ejecutable (para cargar luego los sprites) e inicializamos los sprites que están declarados en este array global:

var
Sprite: array[1..MAX_SPRITES] of TSprite;

El número máximo de sprites que fijé que pueden estar a la vez cargados fue de 50:

const
MAX_SPRITES = 50;

Aunque nunca llegué a gastarlos del todo. Ya veremos la clase TSprite más adelante.

CAMBIANDO EL MODO DE VÍDEO

Como vamos a trabajar con OpenGL, aquí ya hay cambios importantes respecto a la rutinas de SDL tradicionales:

procedure ModoVideo(iAncho, iAlto, iProfundidadColor: Integer; bPantallaCompleta: Boolean);
begin
SetEnvironmentVariable('SDL_VIDEO_CENTERED', '1');

SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 32);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);

// Activamos la sincronización vertical
SDL_GL_SetAttribute(SDL_GL_SWAP_CONTROL, 1);

// Pasamos a modo de video de la ventana especificada
if bPantallaCompleta then
Pantalla := SDL_SetVideoMode(iAncho, iAlto, 32, SDL_FULLSCREEN or SDL_OPENGL)
else
Pantalla := SDL_SetVideoMode(iAncho, iAlto, 32, SDL_OPENGL);

if Pantalla = nil then
begin
ShowMessage( 'Error al cambiar de modo de video.' );
SDL_Quit;
Exit;
end;

InicializarOpenGL(iAncho, iAlto);
end;

Vamos a analizar este procedimiento porque es la base de todo. Antes de hacer nada le decimos a la SDL que en el caso de que ejecutemos el juego en modo ventana me la centre en el escritorio. Esto lo hacemos creando una variable de entorno:

SetEnvironmentVariable('SDL_VIDEO_CENTERED', '1');

Después le decimos a la librería OpenGL que vamos a renderizar polígonos con 32 bits de color y que active el doble buffer:

SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 32);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);

Y aquí tenemos la razón de porque pase todo el juego a OpenGL:

// Activamos la sincronización vertical
SDL_GL_SetAttribute(SDL_GL_SWAP_CONTROL, 1);

Luego activamos el modo de vídeo en modo ventana o pantalla completa según lo que nos pasen como parámetro:

if bPantallaCompleta then
Pantalla := SDL_SetVideoMode(iAncho, iAlto, 32, SDL_FULLSCREEN or SDL_OPENGL)
else
Pantalla := SDL_SetVideoMode(iAncho, iAlto, 32, SDL_OPENGL);

if Pantalla = nil then
begin
ShowMessage('Error al cambiar de modo de video.');
SDL_Quit;
Exit;
end;

La variable Pantalla es una superficie de SDL donde vamos a renderizar todos los polígonos:

var
Pantalla: PSDL_Surface; // Pantalla principal de vídeo

PREPARANDO LA ESCENA OPENGL

Al final del procedimiento ModoVideo llamamos a otro procedimiento llamado InicializarOpelGL que inicializa la escena donde vamos a dibujar los polígonos:

procedure InicializarOpenGL(iAncho, iAlto: Integer);
var
rRatio: Real;
begin
rRatio := CompToDouble(iAncho) / CompToDouble(iAlto);

// Suavizamos los márgenes de los polígonos
glShadeModel(GL_SMOOTH);

// Ocultamos las caras opuestas
glCullFace(GL_BACK);
glEnable(GL_CULL_FACE);

// Ponemos el negro como color de fondo
glClearColor(0, 0, 0, 0);

// Configuramos el zbuffer
glClearDepth(1);

// Establecemos la perspectiva de visión
gluPerspective(60, rRatio, 1.0, 1024.0);

// Habilitamos el mapeado de texturas
glEnable(GL_TEXTURE_2D);

// Activamos el z-buffer
glEnable(GL_DEPTH_TEST);
glDepthMask(TRUE);

// Sólo se dibujarán aquellas caras cuyos vértices se hallen en sentido
// de las agujas del reloj
glFrontFace(GL_CW);

glViewport(0,0,640,480);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glOrtho(0,640,0,480,-100,100);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
end;

Lo primero que hacemos en este procedimiento es calcular el ratio de perspectiva de la cámara respecto a al ancho y alto de la superficie:

var
rRatio: Real;
begin
rRatio := CompToDouble(iAncho) / CompToDouble(iAlto);

Luego configuramos la impresión de polígonos para que suavice los bordes dentados que aparecen en los márgenes de cada polígono. Esto es importante porque entre esta función y el suavizado utilizando el canal alfa da un aspecto a los sprites de dibujos animados:

glShadeModel(GL_SMOOTH);


Para dar un toque de suavidad a los sprites también hemos dibujado el contorno de los mismos de color negro pero dando una pequeña semitransparencia para que se mezcle con el fondo. Aunque el juego esté solo a 640 x 480 parece que está a más resolución.

Ahora le decimos que vamos a ocultar los polígonos con las caras opuestas. Esto sobre todo tiene más sentido en juegos 3D:

glCullFace(GL_BACK);
glEnable(GL_CULL_FACE);

Le estamos diciendo que sólo queremos que imprima los polígonos que estén mirando a la cámara según el orden que demos a los vértices que veremos más adelante. Imaginaos un cubo en 3D:

Si os dais cuenta, sólo se ven tres caras a la vez. Las otras tres caras siempre quedan ocultas. Entonces, ¿para que imprimirlas? Los responsables de OpenGL ya pensaron en esta situación y crearon esta opción para que sólo se impriman los polígonos que están de cara a la cámara. Como nuestro juego es en 2D siempre se van a ver todos. Pero es bueno activarlo por si alguna vez metemos una rotación.

Le indicamos que el fondo de la pantalla va a ser de color negro:

glClearColor(0, 0, 0, 0);

El cuarto componente es el canal alfa (la transparencia). Luego configuramos la profundidad del Z-Buffer:

glClearDepth(1);

El Z-Buffer en este caso es muy importante. Determina el orden de superposición de polígonos. Si no lo activamos lo mismo aparece un enemigo delante del personaje que detrás, incluso podría provocar parpadeos si se cruzan dos polígonos que están en la misma coordenada Z. Ya veremos esto detenidamente con los sprites.

Fijamos la perspectiva de la cámara:

gluPerspective(60, rRatio, 1.0, 1024.0);

Esta perspectiva determina el enfoque de la cámara respecto a la escena. Podemos acercarla o alejarla a nuestro antojo. En este caso la he ajustado exactamente a la pantalla. Ahora activamos la carga de texturas:

glEnable(GL_TEXTURE_2D);

Activamos el buffer Z:

glEnable(GL_DEPTH_TEST);
glDepthMask(TRUE);

La siguiente función le indica el sentido de los vértices de los polígonos (en el sentido de las agujas del reloj):

glFrontFace(GL_CW);

Todos los polígonos que cuyo órden de los vértices esté al contrario de las agujas del reloj no se imprimirán.

A continuación fijamos el ancho y alto de la proyección en pantalla así como el sistema de coordenadas:

glViewport(0, 0, iAncho, iAlto);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glOrtho(0,640,0,480,-100,100);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();

La librería OpenGL es tan flexible que permite establecer que rango van a tener nuestras coordenadas. En este caso van a ser de 0 a 640 horizontalmente y de 0 a 480 verticalmente. Originalmente el eje de coordenadas está en el centro de la pantalla lo que es un coñazo a menos que vayamos a hacer juegos en 3D con coordenadas de polígonos que vengan de programas como 3D Studio, Maya, etc.

Aquí la función más importante es glOrtho que pasa el sistema de coordenadas de 3D a un sistema ortogonal 2D. Si lo hubiésemos dejado en 3D daría la sensación de que las piezas se resquebrajan en las orillas de la pantalla y se quedan bien en el centro. Por ello quitamos la perspectiva tridimensional. Con este sistema, independientemente de la coordenada Z de los polígonos siempre se verá igual ante la cámara, sin profundidad.

Esto ya lo aclararé cuando llegue a la rutina de impresión de polígonos. En el próximo artículo veremos la clase TSprite y como lo convertí todo a OpenGL.

Pruebas realizadas en Delphi 7.

18 septiembre 2009

Mi primer videojuego independiente (1)

Durante una serie de artículos os voy a explicar mi pequeña experiencia en lo que se refiere a la programación de mi primer videojuego independiente y comercial así como su venta y distribución. Sobre todo voy a orientarme en la programación en Delphi.

Lo que voy a explicar viene a ser una extensión de esta serie de artículos que escribí hace tiempo sobre programación con SDL:

Programar videojuegos con la librería SDL (1)
Programar videojuegos con la librería SDL (2)
Programar videojuegos con la librería SDL (3)
Programar videojuegos con la librería SDL (4)
Programar videojuegos con la librería SDL (5)
Programar videojuegos con la librería SDL (6)
Programar videojuegos con la librería SDL (7)
Programar videojuegos con la librería SDL (8)
Programar videojuegos con la librería SDL (9)
Programar videojuegos con la librería SDL (10)
Programar videojuegos con la librería SDL (11)

Para los que crean que con Delphi sólo se pueden crear aplicaciones de gestión y utilidades les voy a demostrar que están muy equivocados. Trabajando a tiempo parcial y durante 7 meses al final pudimos hacer entre dos personas (programador y diseñador gráfico) el videojuego Pequepon Adventures que he metido en mi página web de Divernova Games.

Y todo con Delphi 7 y programando a pelo en Win32. Ni siquiera llego a utilizar el objeto Application. Podéis descargar la demo de aquí:

http://www.divernovagames.com/pequeponadventures.html

Si hubiese trabajado 8 horas al día de Lunes a Viernes sin matarme, realmente hubiera tardado unos 3 meses en realizarlo. Lo que más que costó fue el diseño de pantallas, ya que el juego tiene 30 niveles con más o menos 10 pantallas cada uno, lo que dan un total de 300 pantallas.

Aunque pueda parecer sencillo al principio, diseñar pantallas es desesperante. Para hacer un videojuego como dios manda creo que harían falta por lo menos cuatro personas (programador, diseñador de niveles, diseñador gráfico y músico).

PARA APRENDER HAY QUE EQUIVOCARSE

Cuando uno es novato en estos temas lo primero que hace es tirarse al toro sin planificar nada y teniendo las ideas más o menos claras pero sin un objetivo concreto. Durante muchos años he intentado hacer videojuegos primero en lenguaje ensamblador programando directamente los gráficos a la SVGA mediante puertos, luego en C/C++ con las librerías Allegro y SDL para terminar programando el Delphi con SDL y OpenGL. Eso sin contar con los DIV Games Studio, Fenix, GameMaker y demás hierbas.

Pero lo que pasa siempre es que empiezas el juego con ilusión y conforme van pasando los meses y va incrementándose la dificultad al final abandonas creyendo que el lenguaje o la librería gráfica que estas utilizando no son lo suficientemente buenos y comienzas a programar en otros lenguajes más potentes.

Pero el problema no esta en los lenguajes, está en nosotros mismos. Hay que comenzar con un proyecto pequeño y abarcable a corto plazo, da igual que sea cutre y pequeño, lo importante es aprender a terminarlo en un tiempo estimado y sin errores.

Podéis comenzar con algo como un Tetris, un juego de objetos ocultos o una pequeña aventura gráfica con un plazo no superior a tres meses. Una vez terminado el primer juego tendremos la moral suficiente para comenzar el siguiente con más calidad y contenidos.

Tampoco creo lo que dicen muchos que para crear un buen proyecto hay que estar motivado e ilusionado. Con el tiempo la ilusión se acaba y terminas abandonando. Creo que hay que tener una meta clara: ganar dinero. Lo demás son tonterías. Tu modelo de negocio puede basarse en hacer algo gratuito que descargue mucha gente y vivir de la publicidad, o bien cobrar por copia (arriesgándonos siempre con el pirateo).


EL DESARROLLO DE PEQUEPON ADVENTURES

Uno no se explica como un juego tan pequeño puede dar tantos quebraderos de cabeza. Y es por una sencilla razón: la falta de experiencia. Ya puedes leerte 100 libros de programación en los mejores lenguajes o en las librerías OpenGL o DirectX, pero hasta que no te pones a programar no te puedes ni imaginar los problemas que van a salir.

Después de tirarme 6 meses programando todo el videojuego, me puse a probarlo en otros equipos y me llevé un chasco impresionante. La librería SDL en modo 2D (sin utilizar aceleración gráfica) funciona muy bien cuando las pantallas son estáticas, pero si realizamos un scroll entonces el retrazo vertical de algunas tarjetas gráficas llega a crear unos cortes tan feos y parpadeantes que pueden matar a un epiléctico.

Me ofusqué buscando por Internet como activar la sincronización vertical en este modo de vídeo pero no encontré absolutamente nada. Todo el mundo decía que sólo funciona cuando la SDL dibuja polígonos OpenGL.

Así que después de una semana maldiciendo mi suerte me propuse dedicarme un mes más a cambiar todo el motor gráfico a OpenGL con las siguientes ventajas y dificultades:

- Todas las rutinas de dibujado de sprites hay que rehacerlas de nuevo.

- Hay que dibujar con polígonos con un ancho y alto que sea potencia de 2 (32x32, 64x64, 128x256, 32x128, etc.).

- Los polígonos no pueden superar un máximo de 256x256. Si se puede pero no todas las tarjetas lo soportan, por lo que si queréis que vuestro videojuego funcione en el mayor número de ordenadores posible no hay que pasarse de ese rango. Supongo que las últimas versiones de DirectX se pasarán esto por el forro.

- Las texturas de los polígonos hay que enviarlas todas de una vez a la memoria de la tarjeta de vídeo antes de comenzar a dibujar. No podemos subir o eliminar texturas en tiempo real porque el desastre puede ser impresionante.

- Por fin podemos activar el retrazo vertical.

- No es necesario crear pantallas temporales ni doble o triple buffer. OpenGL se encarga de todo.

- Aunque OpenGL puede dibujar polígonos de todo tipo yo prefiero partirlo todo en triángulos para que la tarjeta gráfica no tenga que complicarse la vida, aunque las últimas GPU ya ha hacen de todo. Por tanto, cada sprite tiene dos polígonos (triángulos).

- Al dibujar sprites por hardware el consumo de recursos es mínimo. Si ejecutaís Pequepon Adventures en modo ventana (ver Opciones) y abrís el Administrador de tareas de Windows veréis que a veces el juego llega a consumir entre un 0 y un 5% de procesador y todo a 40 frames por segundo. Esto es importantísimo en portátiles para que dure más la batería.

El resultado fue menos traumático de lo que me creía ya que aproveché el código fuente que tenía en C++ de hace años cuando intenté hacer videojuegos 3D en OpenGL con mi propio motor 3D tipo Quake, pero nunca lo terminé por lo que he hablado: el mucho abarca poco aprieta.

Así que en estos artículos pondré fragmentos de código referentes al este nuevo motor 2D pero con aceleración gráfica OpenGL. Como comprenderéis no voy a dar todo el código fuente del juego por dos razones: es un juego comercial y que son 9.700 líneas de código. Pero sí hablaré de las partes que he considerado más difíciles.


EL EDITOR DE PANTALLAS

Programar un juego no sólo es hacer un motor 2D o 3D. Tenemos que crear herramientas externas que guarden la información que necesitamos. En mi caso fue el editor de pantallas. El juego esta programado a una resolución de 640 x 480. Lo que hice fue partir la pantalla en piezas (tiles) de 40 x 40 pixels, lo que sale un total de 16 piezas horizontales y 12 verticales:

Aquí cometí mi primer error. Al principio pensamos en hacer un pequeño videojuego gratuito tipo Snowy con una sola pantalla por nivel. Pero vimos que el juego se hacía muy corto. O hacíamos las piezas más pequeñas o le metíamos más pantallas por la derecha haciendo el Pequepon saltara a la siguiente pantalla cuando llegara a la parte derecha de la pantalla.

Pero entonces se me cruzaron los cables e intenté hacer un scroll. Al principio me salió lento y horrible. Pero entonces comencé a optimizar el motor y llegué a hacer un scroll suave y aceptable que se mueve de 3 en 3 pixels.

El error cometido fue el tener que dibujar entre dos pantallas. Cuando estas entre la primera y la segunda pantalla tienes que dibujar un trozo de cada, con la dificultad que conlleva. Cuando lleguemos a la parte de mi motor 2D os diré como resolví el problema.

Lo que debería haber hecho es un mundo gigante con un buen array bidimensional que abarque todas las pantallas de ese nivel. Ya he aprendido la lección para el siguiente juego. Pero volvamos al editor.

El editor es una aplicación normal de Delphi que contiene un solo formulario:

El juego se dibuja a tres capas:

1º capa: pantalla de fondo.

2º capa: las piezas con las que choca pequepon (suelos, paredes, bloques, etc.).

3º capa: los objetos que recogemos (fresas, llave, esfera, puerta, etc.).

En la parte derecha del editor tenemos una columna para las piezas y otra para los objetos. Como hay más piezas y objetos de lo que pueden caber en pantalla, lo que hice fue meter los bitmaps de las piezas y los objetos dentro de un componente TScrollBox para poder llegar a todas moviendo la barra de desplazamiento vertical.

Si selecciono una pieza o un objeto coloco una flecha verde a su lado. Luego cuando nos vamos a la pantalla si pinchamos con el botón izquierdo del ratón dibujamos piezas y si lo hacemos con el botón derecho del ratón ponemos objetos. La primera pieza y el primer objeto los he dejado transparentes.

¿Por qué el fondo de las piezas son de color rosa? Pues porque es el color que utilizo de máscara para dibujarlas. No me interesaba el negro porque tanto los personajes como los objetos tienen el borde negro.

Entonces en el evento OnMouseDown del formulario compruebo primero si estamos dentro de la pantalla y comenzamos a dibujar:

procedure TFPrincipal.FormMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var i, j: Integer;
begin
if Button = mbLeft then
begin
bIzquierdo := True;

// ¿Está dentro de la pantalla?
if ( x > 40 ) and ( y > 40 ) and ( x <= 680 ) and ( y <= 520 ) then
begin
i := x div 40;
j := y div 40;
EX.Caption := IntToStr( i );
EY.Caption := IntToStr( j );

Piezas[i,j] := iPieza;
DibujarPantalla;
end;
end;

if Button = mbRight then
begin
bDerecho := True;

// ¿Está dentro de la pantalla?
if ( x > 40 ) and ( y > 40 ) and ( x <= 680 ) and ( y <= 520 ) then
begin
i := x div 40;
j := y div 40;
EX.Caption := IntToStr( i );
EY.Caption := IntToStr( j );

Objetos[i,j] := iObjeto;
DibujarPantalla;
end;
end;
end;

Las piezas y los objetos son dos array bidimensionales de bytes, por lo tanto solo podemos poner un máximo de 255 piezas distintas:

var
Piezas, Objetos: array[1..16,1..12] of byte;

Esto no me preocupa porque por cada mundo vuelvo a cargar piezas nuevas:

En cambio, los objetos son iguales para todos los mundos. Aún así sólo he gastado 77 objetos de los 255 que tengo disponibles. Si necesitáis más piezas pues hacéis un array de DWord. La procedimiento de DibujarPantalla es este:

procedure TFPrincipal.DibujarPantalla;
var i, j: Integer;
Origen, Destino: TRect;
begin
// Lo dibujamos todo el el buffer

with Buffer.Canvas do
begin
// Primero dibujamos el fondo
Origen.Top := 0;
Origen.Left := 0;
Origen.Right := 640;
Origen.Bottom := 480;
Destino.Top := 0;
Destino.Left := 0;
Destino.Right := 640;
Destino.Bottom := 480;
CopyMode := cmSrcCopy;
CopyRect( Destino, ImagenFondo.Canvas, Origen );

for j := 1 to 12 do
for i := 1 to 16 do
begin
if Piezas[i,j] > 0 then
begin
Origen.Top := Piezas[i,j] * 40;
Origen.Left := 0;
Origen.Right := 40;
Origen.Bottom := Piezas[i,j] * 40 + 40;
Destino.Top := (j-1)*40;
Destino.Left := (i-1)*40;
Destino.Right := (i-1)*40 + 40;
Destino.Bottom := (j-1)*40 + 40;

// ¿La pieza que va a poner ya no existe?
if Piezas[i,j] > ImagenPiezas.Height div 40 then
begin
Brush.Color := clRed;
FillRect( Destino );
end
else
if MascaraPiezas = nil then
begin
CopyMode := cmSrcCopy;
CopyRect( Destino, ImagenPiezas.Canvas, Origen );
end
else
begin
CopyMode := cmSrcAnd;
CopyRect( Destino, MascaraPiezas.Canvas, Origen );
CopyMode := cmSrcPaint;
CopyRect( Destino, MascaraPiezas2.Canvas, Origen );
end;
end;

if Objetos[i,j] > 0 then
begin
Origen.Top := Objetos[i,j] * 40;
Origen.Left := 0;
Origen.Right := 40;
Origen.Bottom := Objetos[i,j] * 40 + 40;
Destino.Top := (j-1)*40;
Destino.Left := (i-1)*40;
Destino.Right := (i-1)*40 + 40;
Destino.Bottom := (j-1)*40 + 40;

// ¿El objeto que va a poner ya no existe?
if Objetos[i,j] > ImagenObjetos.Height div 40 then
begin
Brush.Color := clYellow;
FillRect( Destino );
end
else
if MascaraObjetos = nil then
begin
CopyMode := cmSrcCopy;
CopyRect( Destino, ImagenObjetos.Canvas, Origen );
end
else
begin
CopyMode := cmSrcAnd;
CopyRect( Destino, MascaraObjetos.Canvas, Origen );
CopyMode := cmSrcPaint;
CopyRect( Destino, MascaraObjetos2.Canvas, Origen );
//CopyRect( Destino, ImagenObjetos.Canvas, Origen );
end;
end;
end;
end;

// copiamos el buffer a pantalla
with Canvas do
begin
Origen.Top := 0;
Origen.Left := 0;
Origen.Right := 640;
Origen.Bottom := 480;
Destino.Top := 40;
Destino.Left := 40;
Destino.Right := 680;
Destino.Bottom := 520;
CopyMode := cmSrcCopy;
CopyRect( Destino, Buffer.Canvas, Origen );
end;
end;

Dibujar con el canvas de Delphi es un auténtico coñazo. Tenemos primero que cargar los sprites, crear una máscara y luego dibujar la máscara y después los sprites. Para ello tuve que declarar primero todas estas imágenes:

var
MascaraPiezas, MascaraPiezas2, Buffer: TImage;
MascaraObjetos, MascaraObjetos2: TImage;

Y para crear la máscara recorro todos los pixels de la imagen y si el color es rosa entonces invierto los pixels o los elimino:

procedure TFPrincipal.CrearMascaraPiezas;
var i, j: Integer;
begin
// Creamos la máscara de color blanco con el contorno del sprite
MascaraPiezas := TImage.Create( nil );
MascaraPiezas.Width := ImagenPiezas.Width;
MascaraPiezas.Height := ImagenPiezas.Height;

MascaraPiezas2 := TImage.Create( nil );
MascaraPiezas2.Width := ImagenPiezas.Width;
MascaraPiezas2.Height := ImagenPiezas.Height;

for j := 0 to ImagenPiezas.Height - 1 do
for i := 0 to ImagenPiezas.Width - 1 do
begin
if ImagenPiezas.Canvas.Pixels[i,j] = $FF00FF then
begin
MascaraPiezas.Canvas.Pixels[i,j] := clWhite;
MascaraPiezas2.Canvas.Pixels[i,j] := clBlack;
end
else
begin
MascaraPiezas.Canvas.Pixels[i,j] := clBlack;
MascaraPiezas2.Canvas.Pixels[i,j] := ImagenPiezas.Canvas.Pixels[i,j];
end;
end;
end;

Luego hay que acordarse de destruirlo todo al cerrar el formulario:

procedure TFPrincipal.FormDestroy(Sender: TObject);
begin
Suelos.Free;
Paredes.Free;
Matan.Free;
Buffer.Free;
MascaraObjetos.Free;
MascaraObjetos2.Free;
MascaraPiezas.Free;
MascaraPiezas2.Free;
end;

Tanto para el videojuego como para este editor utilicé el experto EurekaLog como mi compañero inseparable. Cuando seleccionamos Archivo -> Abrir cargo la pantalla:

procedure TFPrincipal.AbrirPClick(Sender: TObject);
begin
Abrir.InitialDir := sRutaDat;
Abrir.Filter := 'Pantalla (*.pan)|*.pan';

if Abrir.Execute then
begin
sArchivoPantalla := Abrir.FileName;
CargarPantalla( sArchivoPantalla );
ETituloPantalla.Caption := ExtractFileName ( Abrir.FileName );
end;
end;

La rutina de cargar pantalla debe cargar el fondo que tiene esta pantalla, las piezas, los objetos, la posición del heroe, los enemigos, etc.:

procedure TFPrincipal.CargarPantalla( sArchivo: String );
var
F: File of byte;
i, j, iNumSue, iNumPar, iNumMat, iNumSueReal, iNumParReal, iNumMatReal: Integer;
b: Byte;
begin
AssignFile( F, sArchivo );
Reset( F );

// Cargamos las piezas
for j := 1 to 12 do
for i := 1 to 16 do
Read( F, Piezas[i,j] );

// Cargamos los objetos
for j := 1 to 12 do
for i := 1 to 16 do
Read( F, Objetos[i,j] );

// Leemos el nombre de el archivo de piezas
sArchivoPiezas := '';
for i := 1 to 30 do
begin
Read( F, b );
if b <> 32 then
sArchivoPiezas := sArchivoPiezas + Chr( b );
end;
CargarPiezas( sRutaGfcs + sArchivoPiezas + '.bmp' );

// Leemos el nombre de el archivo de fondo
sArchivoFondo := '';
for i := 1 to 30 do
begin
Read( F, b );
if b <> 32 then
sArchivoFondo := sArchivoFondo + Chr( b );
end;
CargarFondo( sRutaGfcs + sArchivoFondo + '.bmp' );

// Leemos el nombre de el archivo de piezas
sArchivoObjetos := '';
for i := 1 to 30 do
begin
Read( F, b );
if b <> 32 then
sArchivoObjetos := sArchivoObjetos + Chr( b );
end;
CargarObjetos( sRutaGfcs + sArchivoObjetos + '.bmp' );

Suelos.Clear;
Paredes.Clear;
Matan.Clear;

if not Eof(F) then
begin
// Leemos el número de suelos
Read( F, b );
iNumSue := b;

// Leemos el número de paredes
Read( F, b );
iNumPar := b;

// Leemos los suelos
iNumSueReal := 0;
for i := 1 to iNumSue do
begin
if not Eof(F) then
Read( F, b );

if b < ImagenPiezas.Height div 40 then
begin
Suelos.Add( IntToStr( b ) );
Inc(iNumSueReal);
end;
end;
iNumSue := iNumSueReal;

// Leemos las paredes
iNumParReal := 0;
for i := 1 to iNumPar do
begin
if not Eof(F) then
Read( F, b );

if b < ImagenPiezas.Height div 40 then
begin
Paredes.Add( IntToStr( b ) );
Inc(iNumParReal);
end;
end;
iNumPar := iNumParReal;

// Leemos el número que matan
if not Eof(F) then
begin
Read( F, b );
iNumMat := b;

// Leemos los que matan
iNumMatReal := 0;
for i := 1 to iNumMat do
begin
if not Eof(F) then
Read( F, b );

if b < ImagenPiezas.Height div 40 then
begin
Matan.Add(IntToStr(b));
Inc(iNumMatReal);
end;
end;

iNumMat := iNumMatReal;
end;
end;

CloseFile( F );
sArchivo := Abrir.FileName;
DibujarPantalla;
MostrarSuelosParedes;
end;

Los objetos también los utilizo para colocar los enemigos en pantalla y saber que recorrido van a tener. Para delimitar los movimientos de los enemigos cree un objeto especial (la X) que se ve en el editor pero no en el juego. Cuando un enemigo encuentra este objeto da la vuelta:


Los objetos de los enemigos tampoco se verán en el juego. Me valen para saber donde comienza cada enemigo y el movimiento que va a hacer. Para hacer el editor más cómodo también hice que si dejamos pulsado los botones izquierdo o derecho del ratón podemos dibujar una columna o fila de bloques sin tener que hacer clic cada vez. Esto se hace con el evento OnMouseMove:

procedure TFPrincipal.FormMouseMove(Sender: TObject; Shift: TShiftState; X,
Y: Integer);
var i, j: Integer;
begin
// ¿Está dentro de la pantalla?
if ( x > 40 ) and ( y > 40 ) and ( x <= 680 ) and ( y <= 520 ) then
begin
i := x div 40;
j := y div 40;

if ( i < 1 ) or ( i > 16 ) or ( j < 1 ) or ( j > 12 ) then
Exit;

EX.Caption := IntToStr( i );
EY.Caption := IntToStr( j );

// ¿Está pulsado el botón izquierdo del ratón?
if bIzquierdo then
begin
Piezas[i,j] := iPieza;
DibujarPantalla;
end;

// ¿Está pulsado el botón izquierdo del ratón?
if bDerecho then
begin
Objetos[i,j] := iObjeto;
DibujarPantalla;
end;
end;
end;

Pero como tenemos que saber si hemos dejado pulsado el botón izquierdo o derecho del ratón entonces creé dos variables globales:

var
bIzquierdo: Boolean; // ¿Está pulsado el botón izquierdo del ratón?
bDerecho: Boolean; // ¿Está pulsado el botón derecho del ratón?

Y en los eventos OnMouseDown que hemos visto antes y en el evento OnMouseUp controlo cuando el usuario los ha apretado o soltado:

procedure TFPrincipal.FormMouseUp( Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer );
begin
if Button = mbLeft then
bIzquierdo := False;

if Button = mbRight then
bDerecho := False;
end;

Lo que más me costó al principio fue la rutina de guardar pantalla ya que hay que hay que guardar el nombre del bitmap de fondo, las piezas, los objetos (y enemigos) así como saber con que piezas choca el personaje y los enemigos:

procedure TFPrincipal.GuardaPantalla;
var
F: file of byte;
i, j: Integer;
Espacio, b: Byte;
begin
if sArchivoPiezas = '' then
begin
Application.MessageBox( 'Debe seleccionar el archivo de las piezas.',
'Atención', MB_ICONEXCLAMATION );
Exit;
end;

if sArchivoFondo = '' then
begin
Application.MessageBox( 'Debe seleccionar la imagen de fondo.',
'Atención', MB_ICONEXCLAMATION );
Exit;
end;

if sArchivoObjetos = '' then
begin
Application.MessageBox( 'Debe seleccionar el archivo de los objetos.',
'Atención', MB_ICONEXCLAMATION );
Exit;
end;

// Guardamos las piezas y objetos de la pantalla
AssignFile( F, sArchivoPantalla );
Rewrite( F );
Espacio := 32;

// Piezas
for j := 1 to 12 do
for i := 1 to 16 do
Write( F, Piezas[i,j] );

// Objetos
for j := 1 to 12 do
for i := 1 to 16 do
Write( F, Objetos[i,j] );

// Guardamos el nombre del archivo de las piezas
for i := 1 to 30 do
if i <= Length( sArchivoPiezas ) then
Write( F, Byte( sArchivoPiezas[i] ) )
else
Write( F, Espacio ); // espacio en blanco

// Guardamos el nombre del archivo de fondo
for i := 1 to 30 do
if i <= Length( sArchivoFondo ) then
Write( F, Byte( sArchivoFondo[i] ) )
else
Write( F, Espacio ); // espacio en blanco

// Guardamos el nombre del archivo de los objetos
for i := 1 to 30 do
if i <= Length( sArchivoObjetos ) then
Write( F, Byte( sArchivoObjetos[i] ) )
else
Write( F, Espacio ); // espacio en blanco

// Guardamos el número de suelos
b := Suelos.Count;
Write(F, b);

// Guardamos el número de paredes
b := Paredes.Count;
Write(F, b);

// Guardamos los suelos
for i := 0 to Suelos.Count-1 do
begin
b := StrToInt(Suelos[i]);
Write(F, b);
end;

// Guardamos las paredes
for i := 0 to Paredes.Count-1 do
begin
b := StrToInt(Paredes[i]);
Write(F, b);
end;

// Guardamos el número que matan
b := Matan.Count;
Write(F, b);

// Guardamos los que matan
for i := 0 to Matan.Count-1 do
begin
b := StrToInt(Matan[i]);
Write(F, b);
end;

CloseFile( F );
end;

Si os fijáis en las columnas de las piezas y los objetos a la izquierda guardo si cada pieza es libre o es suelo o pared: Estoy es muy importante para controlar si nuestro personaje va a chocar sólo verticalmente al caer o choca por todos lados, por ejemplo con el bloque de piedra. Aunque mi editor lleva mucho más código creo que he comentado las partes más importantes. Crear un buen editor de niveles es fundamental para evitar programar demasiado. En el próximo artículo comenzaré a explicar el núcleo del juego y el motor 2D creado con SDL + OpenGL.
Pruebas realizadas en Delphi 7.

Publicidad