Pintando en el Spectrum (12)

Llega el momento de hacer el mapa. Para ello tenemos que recordar que el Spectrum no tiene la capacidad de memoria de los ordenadores actuales, lo que significa que no podemos crear pantallas a lo bruto y guardarlas tal cual, sino que tenemos que buscar la manera de guardarlas comprimidas, para que ocupen lo menos posible. Una técnica habitual es utilizar tiles o losetas para crear las pantallas. En ellas, lo que se hace es generar un número pequeño de gráficos, y combinándolos y repitiéndolos de distintas maneras podemos crear multitud de estancias de un mapa ocupando muy poca memoria. Veamos un ejemplo con un trozo de mapa:

Vemos que hay varios elementos repetitivos, como por ejemplo las losetas del suelo; pero en realidad todo está colocado en base a una cuadrícula de elementos de 4×4 caracteres. Las posibles losetas que, de momento, se utilizan en el mapa son las siguientes:

Así, por un lado tenemos las definiciones de las diferentes losetas, y por otro la definición del mapa en sí. En esta última estructura lo único que tenemos que almacenar es qué loseta va en cada posición. Dado que la pantalla mide 32×24 caracteres, y cada posición de loseta mide 4×4 caracteres, tenemos que cada pantalla del mapa ocupará 8×6 = 24 bytes (si sólo empleamos un byte por loseta, claro). Por supuesto, las losetas en sí ocupan más memoria: en este caso, cada loseta de 4×4 caracteres ocupa 128 bytes de píxeles más 16 bytes para atributos de color; en total 144 bytes. Pero la ventaja es que una misma loseta se puede reutilizar varias veces en múltiples pantallas, por lo que el ahorro es muy grande. En la pantalla de arriba vemos que el grueso lo ocupa la loseta superior izquierda, y que las paredes de la izquierda y de arriba están hechas fundamentalmente con dos losetas concretas.

Pintando el mapa

Ahora que expliqué la base, voy a comentar algunos detalles de mi implementación. En primer lugar, las losetas mostradas arriba no tienen máscara de transparencia (con la excepción de la puerta vertical… ya lo explicaré luego). El motivo es que las losetas se utilizan para pintar el fondo de la pantalla, por lo que, dado que no hay nada debajo, no hace falta que tengan transparencia. Esto significa que, a la hora de pintarlas, tengo que tenerlo en cuenta. La primera idea sería poner una comprobación dentro del bucle que copia cada byte de un sprite al buffer de vídeo, pero tiene el inconveniente de que estamos comprobando lo mismo una y otra vez en todos los bytes que copiamos. Como vimos en las primeras entradas, pintar gráficos en pantalla consume mucho tiempo porque hay que copiar una cantidad ingente de bytes, y cualquier pequeño ahorro dentro del bucle supone un aumento brutal del rendimiento, pues se multiplica por los más de seis kilobytes que hay que copiar en cada frame. Es por esto que sólo hago una comprobación justo antes de entrar en el bucle externo (el que pinta las filas), y en función de si el sprite tiene o no máscara, voy a una rutina u otra. La rutina que pinta con máscara ya la vimos en la entrada número 6, así que pondré a continuación únicamente la rutina que pinta sin máscara. Partimos de HL apuntando al sprite, de DE conteniendo la dirección de destino, calculada a partir de las coordenadas, y de BC conteniendo el alto en scanlines y el ancho en caracteres (esos datos los obtenemos con el código común con la rutina que pinta con máscara):

    ld A, C ; guardamos el número de columnas en A
    push DE
    push BC
    ld B, 0
    exx
    pop BC ; pasamos el número de filas al juego alternativo de registros
    pop IX ; y la dirección de destino a IX
    ld DE, 32 ; valor a sumar para saltar al siguiente scanline
loop1:
    exx
    ld D, IXh
    ld E, IXl ; la pasamos al conjunto de registros principal
    ld C, A ; B vale cero, así que LDIR copiará A bytes
    ldir ; copiamos una fila
    exx
    add IX, DE ; saltamos al siguiente scanline
    djnz loop1

Esta rutina tiene la ventaja de que es mucho más rápida que la que utiliza máscaras, gracias a que se basa en LDIR. Así, cada byte necesita sólo 23 Testados frente a los 61 que necesitábamos en la rutina con máscaras, lo que es un gran ahorro, y más si nos aseguramos de que el máximo número posible de tiles no usan máscara, la cual la reservaremos sólo para los personajes y objetos móviles, pues ellos sí queremos que se muevan «por encima» de lo que ya está pintado, y para algún tile muy concreto.

Una vez que tenemos ya esta rutina, toca almacenar el mapa. La idea consiste en almacenar un array de bytes. En mi juego no voy a utilizar una distribución clásica de pantallas en la que el personaje se mueve por dentro de la pantalla, y cada vez que sale, la pantalla se borra y se pinta una nueva. En mi caso el personaje estará quieto en el medio de la pantalla, y todo el mapa se moverá a derecha, izquierda, arriba o abajo. Esto significa que, al no haber fronteras definidas, todo el mapa será un solo bloque. Este es el mapa de demostración que hice:

map_array:
    defb 0x0A, 0x0A, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x90, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x8C, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x91, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x02, 0x01, 0x01, 0x01, 0x03, 0x01, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x03, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x03, 0x01, 0x01, 0x01, 0x01, 0x85, 0x01, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x88, 0x01, 0x01, 0x01, 0x04, 0x04, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x89, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x01, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x03, 0x01, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x85, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x85, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x85, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x8E, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x8D, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x86, 0x8F, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A
    defb 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A

El mapa mide 32 tiles (256 caracteres) de ancho. Esto lo hice porque es el máximo valor que se puede almacenar en un byte para las coordenadas, y porque para multiplicar por el ancho sólo hay que rotar cinco veces el valor, lo que es una operación fácil y rápida. Por otro lado, he decidido que si un valor tiene el bit 7 a uno, significa que el personaje no puede pisar esa loseta. Así, las paredes, mesas, camas, etc. tendrán dicho bit a 1 para evitar que el personaje las atraviese, y sólo las losetas normales tendrán dicho bit a 0.

La correspondencia de cada número es como sigue:

; 0 -> no pintar
; 1 -> suelo normal
; 2 -> suelo roto
; 3 -> suelo con rejilla
; 4 -> minisuelo (suelo con una única línea, para muros horizontales)
; 5 -> muro vertical
; 6 -> muro horizontal
; 7 -> cruce de muros
; 8 -> parte superior de puerta vertical
; 9 -> parte inferior de puerta vertical
; A -> suelo exterior
; B -> minisuelo exterior (single-line)
; C -> muro en T superior
; D -> muro en T inferior
; E -> esquina inferior izquierda
; F -> esquina inferior derecha
;10 -> esquina superior izquierda
;11 -> esquina superior derecha

Los elementos 0, 4 y B pueden parecer extraños, pero tienen sentido para aquellas losetas que estarán tapadas por otras losetas que midan más de 4×4 caracteres. De hecho, comparemos la loseta 1 con la 4 y la B:

Vemos que son básicamente iguales, salvo porque la 4 y la B son más pequeñas verticalmente. El motivo de que existan estas losetas es que pintar un sprite supone una carga de procesador muy elevada, pues hay que copiar muchos bytes de un lado para otro, por lo que si podemos ahorrar pintar algunos bytes, mejor. Para entenderlo mejor, veamos cómo compongo el mapa para dar, además, apariencia 3D.

Pintando en pseudo3D

La vista del mapa pretende ofrecer un pseudo3D. Al contrario que otros sistemas, como por ejemplo las vistas isométricas, donde la coordenada X e Y de la pantalla sale de una combinación lineal de las coordenadas X, Y y Z de cada objeto, en este sistema la coordenada X de la pantalla es simplemente la misma que la del objeto, y la coordenada Y es la Y del objeto menos la Z. Sin embargo, esto es sólo parte del problema del pintado. La otra parte es decidir el orden en que hay que pintar los objetos para asegurarse de que el efecto pseudo3D es consistente: por ejemplo, que si un personaje pasa por detrás de una mesa o de una pared, quede parcialmente oculto. Para ello es necesario pintar todos los elementos en un orden concreto: de atrás hacia adelante, de manera que los objetos que están más cercanos vayan tapando lo que hay detrás. En el sistema que he escogido esto es muy sencillo: cuanto mayor sea la Y del objeto, más «abajo» en la pantalla estará y, por tanto, habrá que pintarlo más tarde.

Sabiendo esto, el proceso es muy sencillo: si queremos pintar el mapa desde las coordenadas X0,Y0, lo que haremos será pintar primero los tiles cuya coordenada Y sea igual a Y0. Luego pintaremos los personajes cuya coordenada Y sea igual a Y0. A continuación pintaremos los tiles cuya coordenada Y sea igual a Y0+1, y seguiremos con los personajes cuya coordenada Y sea igual a Y0+1. Y así sucesivamente.

Veámoslo de manera gráfica:

En el GIF anterior muestra el orden en el que se van pintando los tiles. Vemos que algunos objetos, como las camas, ocupan más de un tile en vertical. Esto es porque tienen altura, y, por tanto, taparán a un personaje que pase por detrás. La única precaución es que deben pintarse algo más arriba. En mi caso, como todos los tiles del suelo tienen 4 caracteres de altura, si al pintar un elemento éste tiene más, lo que hago es pintarlo tantos caracteres hacia arriba como caracteres extra tenga. Así, al pintar la segunda fila de tiles se ve que un elemento es una cama, que tiene una altura de seis caracteres. Por tanto, debe pintarse en la coordenada Y=2 en lugar de la coordenada Y=4, que sería la que le correspondería a un tile de suelo, por ejemplo. Lo mismo ocurre con la esquina de la pared, en la tercera fila de tiles: dado que mide ocho caracteres de alto, se pinta a partir de Y=4 en lugar de Y=8. Vemos que el resultado es que el personaje, efectivamente, resulta tapado parcialmente por la cama que tiene delante, lo que potencia el efecto 3D.

Se puede ver también que en las zonas de una fila que quedarán cubiertas por un tile de la fila siguiente, he utilizado tiles incompletos, justo de los que hablaba antes. Así, en la fila anterior a una cama, he utilizado suelo que es la mitad de alto, y en la fila anterior de la esquina de la pared directamente no he pintado el tile de la baldosa en la zona que irá ocupada por la parte superior.

¿Por qué hago esto? Simplemente para ahorrar procesador. Podría haber pintado la baldosa completa, de 4×4 caracteres y el resultado habría sido exactamente el mismo. Sin embargo, dado que la siguiente fila sobreescribirá partes de la anterior, realmente estaría pintando dos veces una zona de la pantalla, lo que supone tirar a la basura tiempo de procesador (algo de lo que andamos muy justos), y además haciendo justo lo que más tiempo consume: pintando cosas. Por eso creé esas tiles extra de baldosas con dos y una fila sólo, y por eso existe el tile cero, que directamente no pinta nada en esa posición: para ahorrarnos el pintar algo que sabemos que será borrado al pintar la siguiente fila.

Por supuesto, esto puedo hacerlo con las baldosas porque se desde el principio cuales serán cubiertas por el elemento de la siguiente fila y cuanto. En cambio, vemos que el personaje lo pintamos completo porque de él no podemos saber a priori qué partes serán tapadas por otros objetos. En este caso no hay atajos.

Ah, y si notáis diferencias entre los tiles del principio del artículo y los que se ven en la animación de aquí abajo, es porque cambié los colores para darle un poco más de vidilla a los decorados.

En la siguiente entrada veremos el gestor de tareas y la gestión del movimiento de los personajes.

CC BY-SA 4.0 Pintando en el Spectrum (12) por A cuadros está licenciado bajo una Licencia Creative Commons Atribución-CompartirIgual 4.0 Internacional.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.