Archivo por días: 12 febrero, 2021

Pintando en el Spectrum (14)

El siguiente paso es implementar la lógica del juego. Se trata de la parte del código que hace que funcionen las cosas como deben funcionar. Por ejemplo, se encarga de mover al personaje principal y a los secundarios, animar objetos, etc. Este código se ejecutará justo después de que hayamos pintado todos los gráficos en el buffer secundario y mientras esperamos a que comience el barrido de un nuevo frame.

Esta parte se divide, al menos de momento, en dos piezas de código: una que actualiza la posición de cualquier personaje, y otra que ejecuta las distintas tareas o tasks.

Movimiento de personajes

Dado que los personajes están animados, es necesario tener en cuenta en qué posición se encuentran en cada momento, si están ya donde deben estar o no, la fase de la animación, etc. Además, para ahorrar memoria, tenemos por un lado el cuerpo en sí y por otro las piernas, siendo estas últimas la única parte animada realmente. Eso significa que cada personaje está compuesto realmente por dos sprites, pero sólo uno es el que tiene secuencias de animación. Además, cada bloque está repetido en espejo, para cuando el personaje tiene que moverse hacia el lado opuesto.

Sprites que forman a un personaje. Arriba están los dos cuerpos, uno para cada lado, y debajo de cada uno están las secuencias de animación de las piernas que corresponden a cada uno de los dos cuerpos.

Cada personaje está definido en una estructura como la siguiente:

    DEFB 0x01 ; bit 0: 1-> es el personaje principal
              ; bit 1: 1-> es un personaje secundario
              ; bit 2: 1-> está caminando; 0-> no camina
              ; bit 5: 1-> mira hacia la izquierda; 0-> a la derecha
              ; bit 6-7: etapa de animación
              ; si el byte vale cero, es FIN DE LA TABLA
    DEFB NOWX ; coordenada X actual
    DEFB NOWY ; coordenada Y actual
    DEFB DESX ; coordenada X final
    DEFB DESY ; coordenada Y final
    DEFW sprites_table ; puntero al sprite para el cuerpo
    DEFW (sprites_table + 6) ; puntero al sprite para los pies

Vemos que tenemos, por un lado, la etapa actual de la animación y a qué lado está mirando actualmente el personaje, por otro lado dos juegos de coordenadas, y por último dos punteros a dos entradas de la tabla de sprites, uno apuntando a la entrada que muestra el cuerpo del personaje y otra que apunta a la entrada de las piernas. Cuando queremos que un personaje camine desde donde está actualmente (cuyo valor está almacenado en NOWX, NOWY) hasta otro sitio, debemos simplemente escribir las coordenadas de destino en DESX, DESY, y la rutina de movimiento de personajes se encargará del resto. Esta rutina se ejecuta justo después de pintar todo el buffer secundario, y lo que hace es comparar la coordenada X actual con la X final, y lo mismo con la Y. Si son iguales, pondrá el bit 2 a 0 para indicar que el personaje está en su destino y pasará a la siguiente entrada. Sin embargo, si alguna de las dos coordenadas actuales no es igual a su homóloga final, la rutina le sumará o restará 1, en función de lo que sea necesario para acercar al personaje a su posición final. A continuación copiará las coordenadas actuales en cada una de las dos entradas de la tabla de sprites. Luego incrementará en uno la etapa de animación, y en base al valor de ésta decidirá qué sprite concreto se mostrará para los pies (modificando para ello la entrada correspondiente de la tabla de sprites), además de ajustar la coordenada Z (porque nuestros personajes se mueven a saltos). Tras hacer todo esto, saltará a la siguiente entrada de la tabla y repetirá estas operaciones hasta llegar al final.

Vemos, pues, que esta rutina nos simplifica mucho el mover un personaje desde una zona del mapa hasta otra, pues sólo tenemos que poner las coordenadas finales y esperar a que el bit 2 cambie de 1 a 0 para saber que ha llegado.

Gestor de tareas

La rutina anterior nos ayuda un poco, pero todavía nos queda por implementar absolutamente toda la lógica del juego en sí. La primera idea consiste en escribir una función que se llame justo después de terminar de actualizar las posiciones de los personajes (esto es, justo des pués de la rutina anterior), y que, en base al estado actual del juego (donde está el protagonista, el resto de personajes, los objetos, etc) decida cual debe ser el nuevo estado (donde estará el protagonista y el resto de personajes, si algún objeto aparece o desaparece…), tras lo cual se pintará de nuevo el mapa y se repetirá el proceso.

Por desgracia, hacer algo así complica sobremanera la tarea, y además hace que cualquier cambio que se quiera hacer pueda afectar a otras partes, por lo que el código probablemente quedaría muy complejo. Por eso decidí seguir la idea que Ron Gilbert y compañía utilizaron en Scumm, la máquina virtual que desarrolló para el juego Maniac Mansion: permitir crear múltiples tareas que se ejecuten en paralelo y de manera independiente.

La idea básica es que cada personaje u objeto se maneje desde una tarea independiente, y que como mucho se añadan puntos de sincronización entre ellas. Las tareas se ejecutan mediante multitarea cooperativa, y se ejecutan una vez por frame del juego. Cuando una tarea ha terminado de hacer lo que tenga que hacer, simplemente tiene que hacer una llamada a una subrutina concreta para avisar, cediendo el control a la siguiente. Cuando todas las tareas se han ejecutado, se pintará el siguiente frame, se actualizará la posición de los personajes, y volverán a ejecutarse las tareas una a una, pero con el detalle de que cada una continuará su ejecución justo donde se quedó en el frame anterior, y además la pila o stack conservará todos los valores que la tarea hubiese almacenado en su ejecución previa.

Veamos cómo es la implementación:

task_list:
    DEFW $+TASK_DATA_SIZE+2
    DEFS TASK_DATA_SIZE
    DEFW tarea1
    DEFW $+TASK_DATA_SIZE+2
    DEFS TASK_DATA_SIZE
    DEFW tarea2
    DEFW 0 ; fin de la tabla de tareas


task_run:
    ld (old_stack), SP ; preservamos la pila global
    ld iy, task_list
task_loop:
    ld l, (iy+0)
    ld h, (iy+1)
    ld a, h
    or l
    jr z, task_end
    ld sp, hl ; asignamos a SP la pila de la tarea a ejecutar
    ret ; y saltamos al código de la tarea

task_yield:
    ld hl, 0
    add hl, sp ; almacena en HL el stack actual
    ld (iy+0), l
    ld (iy+1), h ; y lo guarda en la entrada de la tabla de tareas
    ld de, TASK_ENTRY_SIZE
    add iy, de
    jr task_loop ; siguiente entrada de la tabla de tareas

task_end:
    ld SP, (old_stack) ; restauramos la pila global
    ret

Vemos que la tabla está dimensionada para dos tareas, y cada entrada de la tabla de tareas está compuesta de dos partes:

  • Los dos primeros bytes contienen la dirección de la pila o stack de esta tarea (recordemos que $ es «la dirección de esta línea»)
  • El resto de bytes es donde se almacena la pila o stack de esta tarea, con un total de TASK_DATA_SIZE+2 bytes

Vemos que en el código que puse arriba, los dos primeros bytes apuntan a los dos últimos bytes del bloque de la pila de cada tarea, y éstos contienen la dirección de entrada del código de cada tarea. De esta manera, lo que hacemos es inicializar la pila de cada tarea de manera que contenga un único dato: la dirección inicial de entrada para esa tarea.

Si nos vamos al código que viene justo a continuación, vemos que el punto de entrada es task_run. Esta es la función que se llama cada vez que se ha terminado de pintar un frame. Vemos que lo primero que hace es guardar la dirección del stack. Esto es necesario porque vamos a modificarlo para cada tarea, por lo que cuando terminemos, necesitaremos restaurarlo al valor inicial, o no podremos retornar.

A continuación cargamos en IY la dirección inicial de la tabla de tareas, pues vamos a utilizar dicho registro índice para recorrerla. Las tareas no deben modificar este registro bajo ninguna circunstancia.

Entramos ahora en el bucle principal, en el que simplemente tomamos los dos primeros bytes, vemos si ambos valen cero (lo que significaría que hemos llegado al final de la tabla), y si no es así, cargamos su valor en SP.

Ahora el puntero de pila apunta a la pila de la primera tarea, y si recordamos cómo inicializamos la tabla de tareas, el valor que hay en ella será la dirección de inicio de dicha tarea, con lo que al hacer un RET, el procesador sacará ese valor de la pila y saltará a él.

Ahora la primera tarea empezará a ejecutarse. Hará todo lo que tenga que hacer, y cuando haya terminado, simplemente hará un CALL task_yield. Esto significa que ahora la pila de la tarea ya no contiene la dirección inicial, sino justo la dirección desde la que se hizo esa llamada, lo que significa que cuando volvamos a llamar a task_run tras el siguiente frame, al ejecutar el RET se saltará justo a la siguiente instrucción tras el CALL en lugar de al principio, por lo que la tarea continuará su ejecución justo en el punto en el que lo dejó.

En task_yield vemos que lo que hacemos es almacenar el valor de SP en los dos primeros bytes de la entrada de la tabla de tareas. Esto es necesario porque la tarea podría haber almacenado algún valor antes de llamar a task_yield, lo que significa que la dirección de retorno ya no está al final de la pila, sino antes.

Tras ello, sumamos a IY el tamaño de una entrada de la tabla de tareas para así saltar a la siguiente, y repetimos el proceso hasta llegar al final de la tabla.

Veamos un ejemplo de una tarea, en concreto la que gestiona el movimiento del personaje amarillo:

task2:
    ld ix, entrada_en_tabla_de_personajes
task2b:
    ld (ix+3), 40
    ld (ix+4), 42 ; cargamos las nuevas coordenadas a donde debe ir
    call task_wait_for_walk ; esperamos a que llegue
    ld (ix+3), 28
    ld (ix+4), 34 ; cargamos las nuevas coordenadas a donde debe ir
    call task_wait_for_walk ; esperamos a que llegue a su destino
    jr task2b


task_wait_for_walk:
    push ix         ; guardamos en la pila el personaje
    call task_yield ; y cedemos el control
    pop ix          ; en el siguiente frame, recuperamos el personaje
    bit 2, (ix+0)   ; vemos si está caminando
    ret z           ; está parado? retornamos
    jr task_wait_for_walk ; y si aún no llego al destino, repetimos

Vemos que lo primero que hacemos es cargar en IX la dirección de la tabla de personajes correspondiente al personaje amarillo, y justo a continuación entramos en el bucle principal.

El personaje, al arrancar el programa, está en las coordenadas 28,34 (pues así lo definimos en la tabla de personajes, no por otra cosa), así que lo que hacemos es poner 40,42 como coordenadas de destino, y llamamos a task_wait_for_walk. Esta función, como vemos, lo primero que hace es preservar IX y devolver el control para que se ejecuten el resto de tareas y se repinte la pantalla. Cuando, en el siguiente frame, se ejecute de nuevo esta tarea, la ejecución comenzará justo en el POP situado después de la llamada a task_yield. Ningún registro se ha preservado, pero la pila sí se conserva, así que retiramos el valor de IX y comprobamos su bit 2 (que es el que nos dice si aún está caminando para llegar a las nuevas coordenadas, o si ya ha llegado). En este caso todavía no habrá llegado, por lo que volvemos a guardar IX en la pila y cedemos de nuevo el control. Así hasta que, tras varios frames, por fin el bit vale cero, momento en el que directamente retornamos al código que llamó a task_wait_for_walk. Y como ahora ya estamos en 40,42, lo que hacemos es poner ahora como destino 28,34 de nuevo y esperar a que llegue a su destino. De esta manera el personaje lo que hará será caminar desde 28,34 hasta 40,42 y vuelta, una y otra vez.

Vemos que el hecho de que la pila y las direcciones de retorno se conserven simplifica muchísimo el trabajo, pues el código de la tarea puede ceder el control para esperar a que todo el sistema avance sabiendo que continuará ejecutándose en el mismo punto como si nada hubiese pasado. Bueno, o casi nada, pues es cierto que los registros no se conservan entre llamdas a task_yield. Pero para eso basta con que la rutina preserve en la pila todo lo que necesite.

Por supuesto, esto significa que hay que dejar espacio suficiente en la pila para todos los registros que se quieran preservar a la vez, además de todas las direcciones de retorno… ¡y una dirección extra! Pues nos interesa que las interrupciones estén habilitadas mientras ejecutamos las tareas, porque de esta manera podemos aprovechar para ejecutarlas un tiempo que, si no usásemos interrupciones, simplemente tiraríamos a la basura; pero eso significa que puede metersenos un dato extra en la pila con el que, a lo mejor, no contábamos al dimensionarla.

Adaptando la ISR

Por desgracia, esto nos supone un problema extra, pues la ISR tiene que guardar en la pila todos los registros que vaya a utilizar. Esto significa que si dejamos nuestro código tal cual, necesitaríamos reservar en la pila de cada tarea el espacio suficiente para guardar no sólo todos los datos de la tarea, sino también 22 bytes extra, los necesarios para guardar la dirección de retorno de la interrupción y los diez pares de registros del procesador que utilizamos en la ISR (AF, BC, DE, HL, AF’, BC’, DE’, HL’, IX e IY).

Hacer esto sería un verdadero desperdicio de memoria, así que la solución es tan sencilla como reservar una zona de 22 bytes extra en memoria, y nada más entrar en la ISR, guardar el valor de SP al principio de esa zona, cambiarlo para que apunte al final más uno (recordemos que PUSH primero decrementa y luego almacena) del bloque, y sólo entonces guardar los diez pares de registros. Al salir, por supuesto, tras restaurar los registros hay que restaurar también SP, y sólo entonces retornar.

En la próxima entrada hablaré de la impresión de textos.