domingo, 5 de mayo de 2024

Commodore 64C: Programación de Juegos

El ordenador Commodore 64C, como otros ordenadores de la época, tiene un interprete de BASIC que sirve para programar y manejar el ordenador. Los programas en BASIC, al ser interpretados, son bastante lentos. Por eso la mayoría de los juegos y programas se realizaban en lenguaje ensamblador. También se crearon compiladores a código máquina de BASIC y otros lenguajes de programación.

Para explicar como programar un juego para el Commodore 64C he creado un sencillo juego consistente en una bola y una pala con la que hay que evitar que la bola caiga al suelo. Cada vez que se para la bola con la pala se suma un punto a un marcador en la esquina superior-izquierda. Lo he desarrollado en un PC utilizando el emulador VICE y como lenguaje de programación he usado el BASIC V2 incluido en el ordenador. Para que se ejecute más rápido lo he compilado a código máquina con MOSpeed. La creación de juegos para el Commodore 64C consiste principalmente en utilizar el chip gráfico VIC-II imprimiendo en pantalla y leyendo/escribiendo en sus registros. Por esta razón lo explicado en este artículo es aplicable a cualquier otro lenguaje de programación que utilicemos para controlar el chip gráfico.


Mediante el comando PRINT podemos imprimir en la pantalla cualquiera de los caracteres del conjunto de caracteres PETSCII. Algunos de los caracteres son caracteres de control que nos permiten realizar acciones como limpiar la pantalla, cambiar el color del texto, imprimir una nueva linea o ir al principio de la pantalla. Otros caracteres nos permiten dibujar líneas y bloques para crear interfaces de texto. Podemos imprimir los caracteres con el comando CHR$ y el número del carácter. El comando SPC sirve para imprimir espacios en blanco. El comando PRINT por defecto imprime al final una nueva línea. Si queremos continuar imprimiendo en la misma línea debemos escribir al final punto y coma.

70 PO=0
80 PRINT CHR$(147)CHR$(144);
90 FOR X=0 TO 39: PRINT CHR$(102);: NEXT X
100 PRINT "POINTS:"PO"     BALL AND PADDLE"
110 FOR X=0 TO 39: PRINT CHR$(101);: NEXT X

690 FOR X=1 TO 10: PRINT: NEXT X
700 PRINT SPC(15)"GAME OVER"

5080 PO=PO+1: PRINT CHR$(19)CHR$(17)"POINTS:"PO

Cada uno de los registros del chip gráfico tiene una capacidad de 8 bits y tiene asignada una dirección de memoria. Para leer y escribir en memoria desde BASIC se utilizan los comandos PEEK y POKE. Si queremos cambiar el color de fondo de la pantalla debemos escribirlo en el registro con la dirección de memoria 53281. Se pueden usar 16 colores. La dirección de los registros se suele utilizar para nombrarlos.

140 POKE 53281,1

El chip gráfico permite el uso de sprites. Los sprites son objetos gráficos que podemos mover por la pantalla sin necesidad de borrar la pantalla y volver a dibujar los objetos. Tienen 24x21 puntos y ocupan 63 bytes. Para crear estos gráficos podemos usar un editor como Spritemate. Se pueden crear dos tipos de sprites: alta resolución y un solo color o baja resolución con tres colores. En los sprites de alta resolución se utiliza un bit por cada punto, por lo que cada punto tiene dos estados: transparente o color. En los sprites de baja resolución se agrupan los puntos de dos en dos y se utilizan dos bits por cada pareja de puntos. Esto reduce la resolución horizontal a la mitad pero permite que cada pareja de puntos tenga cuatro estados: transparente o tres colores. Yo he creado para el juego dos sprites multicolor, uno para la bola y otro para la pala. Como los puntos pueden ser transparentes, se pueden crear sprites de dimensiones menores a 24x21 puntos. Por ejemplo el sprite que he creado para la bola solo tiene 16x16 puntos.

Para usar los sprites en BASIC se utiliza el comando DATA. Cada byte del sprite se codifica como un número entero entre 0 y 255. Mediante los comandos READ Y POKE se leen los bytes del sprite y se escriben en memoria en bloques de 64 bytes. El último byte puede tener cualquier valor. Por ejemplo el editor Spritemate le da el valor 131. Por último se indica en los registros 2040-2047 los bloques de 64 bytes donde se han escrito los sprites. El número de bloque se calcula dividiendo la dirección de memoria entre 64, por ejemplo 12800 / 64 = 200 y 12864 / 64 = 201.

10 FOR X=12800 TO 12800+63: READ Y: POKE X,Y: NEXT X
20 FOR X=12864 TO 12864+63: READ Y: POKE X,Y: NEXT X
30 POKE 2040,200
40 POKE 2041,201

10010 DATA 0,0,0,0,0,0,0,0,0,0,85,0,0,85,0,1
10020 DATA 105,64,1,105,64,5,170,80,5,170,80,6,170,144,6,170
10030 DATA 144,6,170,144,6,170,144,5,170,80,5,170,80,1,105,64
10040 DATA 1,105,64,0,85,0,0,85,0,0,0,0,0,0,0,131
10050 DATA 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,21
10060 DATA 85,84,21,85,84,90,170,165,90,170,165,106,170,169,106,170
10070 DATA 169,106,170,169,106,170,169,90,170,165,90,170,165,21,85,84
10080 DATA 21,85,84,0,0,0,0,0,0,0,0,0,0,0,0,131

El chip gráfico permite el uso de 8 sprites y en algunos registros cada uno de sus 8 bits se utiliza para un sprite. Si numeramos los sprites del 0 al 7, cada bit activado equivale a la potencia de 2 del número del sprite. Por ejemplo el bit activado del sprite 0 es 20, que es el número decimal 1. Varios bits activados equivalen a la suma de sus números decimales. Los bits activados de los sprites 0 y 1 suman 1+2=3, que en binario es 00000011. Sumando todos los números el resultado es 255, el máximo valor que puede tener el registro de 8 bits.

SprideBinarioPotencia de 2Decimal
000000001201
100000010212
200000100224
300001000238
4000100002416
5001000002532
6010000002664
71000000027128

Si para cada sprite creamos una variable con su número decimal, luego la podremos utilizar para leer, activar o desactivar sus bits en los registros. Por ejemplo en el registro 53276 podemos activar los bits de los dos sprites para indicar que son multicolor utilizando la suma de sus números decimales. El interprete de BASIC del ordenador solo tiene en cuenta las dos primeras letras del nombre de las variables. Así que aunque se pueden utilizar nombres más largos es conveniente solo utilizar una o dos letras para evitar confusiones. Con el comando REM podemos añadir comentarios al código.

50 B=1: REM BIT BOLA
60 P=2: REM BIT PALA

120 POKE 53276,B+P

Dos de los colores que pueden tener los sprites multicolor se aplican a todos los sprites y se fijan en los registros 53285 y 53286. El tercer color puede ser distinto para cada uno de los sprites y se indica en los registros 53287–53294. Yo he usado el registro 53285 para asignar el color negro al borde de los sprites y los registros 53287-8 para dar un color distinto al interior. Utilizando el comando RND podemos asignar un color aleatorio. Primero es necesario inicializar el generador de números aleatorios ejecutando RND con un valor negativo y diferente cada vez, por ejemplo el valor negativo de la variable TIME/TI.

150 POKE 53285,0
160 X=RND(-TI)
170 POKE 53287,(RND(1)*14)+2
180 POKE 53288,(RND(1)*14)+2

Si queremos que alguno de los sprites sea más grande tenemos la posibilidad de doblar su tamaño horizontal y vertical activando los bits de los registros 53277 y 53271. De esta forma pude hacer que la pala tenga el doble de tamaño horizontal, 48 puntos.

130 POKE 53277,P

Cada sprite debe tener unas coordenadas horizontales y verticales en la pantalla. Estas coordenadas se fijan en los registros 53248-53263. Los dos primeros registros son para las coordenadas del primer sprite y así sucesivamente. El origen de coordenadas es la esquina superior-izquierda. Para los registros que vayamos a utilizar varias veces podemos utilizar una variable. También podemos sumar números a estas variables para acceder a registros contiguos. En el registro 53269 se deben activar los bits de los sprites para hacerlos visibles.

270 SR=53248
280 BR=SR
290 PR=SR+2
300 POKE BR,(RND(1)*200)+50
310 POKE BR+1,(RND(1)*100)+50
320 POKE PR,(RND(1)*200)+50
330 POKE PR+1,233
340 POKE 53269,B+P

Para mover los sprites de un lado a otro de la pantalla solo necesitamos cambiar sus coordenadas. Para controlar la dirección del movimiento horizontal y vertical de los sprites he utilizado tres variables: BH (Bola Horizontal), BV (Bola Vertical) y PH (Pala Horizontal). Mediante los comandos RND e INT se les asigna aleatoriamente un 0 o un 1. Si el resultado es un 0 se les asigna -1. El valor 1 equivale a avanzar un punto hacia la derecha o hacia abajo y el valor -1 hacia la izquierda o hacia arriba. El comando INT se utiliza para redondear el resultado de RND.

360 BH=INT(RND(1)*2)
370 BV=INT(RND(1)*2)
380 PH=INT(RND(1)*2)
390 IF (BH=0) THEN BH=-1
400 IF (BV=0) THEN BV=-1
410 IF (PH=0) THEN PH=-1

Una vez que están todos los componentes inicializados debemos crear un bucle infinito en el que se produzca el movimiento e interacción entre los sprites. Podemos usar el comando FOR con el parámetro STEP a 0. Como primer paso del bucle se leen las coordenadas de la bola con el comando PEEK.

420 FOR X=0 TO 1 STEP 0
430 BX=PEEK(BR)
440 BY=PEEK(BR+1)

680 NEXT X

El registro de la coordenada horizontal de los sprites, al ser de 8 bits, solo puede contener valores entre 0 y 255. Como la pantalla tiene una resolución de más de 256 puntos horizontales, es necesario utilizar otro bit para indicar que la coordenada horizontal del sprite se encuentra a continuación de la coordenada 255. En el registro 53264 hay un bit extra para cada sprite con esta finalidad. Para simplificar la asignación de coordenadas, al principio del programa se pueden desactivar todos los bits del registro 53264 y fijar las coordenadas horizontales en los primeros 256 puntos.

250 ER=53264
260 POKE ER,0

Para saber si está activado el bit de un sprite podemos usar el operador AND con el valor del registro y el número del sprite. Este operador realiza una operación AND a nivel de bits y su resultado es 1 cuando los dos bits tienen el valor 1. En el resto de casos el resultado es 0. Si el registro tiene activado el bit del sprite el resultado será el número del sprite. De lo contrario el resultado será 0.

450 BE=PEEK(ER) AND B

Registro:  10100001
Sprite:    00000001
Resultado: 00000001

Cuando la bola choca con uno de los bordes laterales o superior, rebota y cambia su dirección de movimiento. En el movimiento horizontal es necesario tener en cuenta el bit extra para saber la coordenada horizontal. También es necesario activar y desactivar el bit extra cuando se llega al límite de la coordenada horizontal 255.

  • Si el bit extra está desactivado y la bola llega a la coordenada horizontal 20 es que ha chocado con el borde izquierdo y se cambia su dirección horizontal hacia la derecha.
  • Si el bit extra está activado y la bola llega a la coordenada horizontal 68 es que ha chocado con el borde derecho y se cambia su dirección horizontal hacia la izquierda.
  • Si la bola llega a la coordenada vertical 47 es que ha chocado con el borde superior y se cambia su dirección vertical hacia abajo.
  • Si la bola llega a la coordenada vertical 231 es que no ha sido parada por la pala y ha chocado con el borde inferior. Entonces se termina el juego saltando a la línea 690, fuera del bucle infinito, mediante el comando GOTO.
  • Si la bola llega a la coordenada horizontal 0 y la bola se mueve hacia la izquierda se ejecuta la subrutina de la línea 1000 mediante el comando GOSUB para desactivar el bit extra y cambiar la coordenada horizontal a 255.
  • Si la bola llega a la coordenada horizontal 255 y la bola se mueve hacia la derecha se ejecuta la subrutina de la línea 2000 mediante el comando GOSUB para activar el bit extra y cambiar la coordenada horizontal a 0.

Después de realizarse las modificaciones necesarias a las variables de dirección del movimiento, se actualizan las coordenadas de la bola sumándole estas variables en las líneas 550 y 560. Las subrutinas de las líneas 1000 y 2000 modifican la coordenada horizontal, por ello después de ejecutar estas subrutinas se salta a la línea 560 para no modificar la coordenada horizontal por segunda vez en la línea 550.

490 IF (BE=0 AND BX=20) THEN BH=1 
500 IF (BE=B AND BX=68) THEN BH=-1
510 IF (BY=47) THEN BV=1
520 IF (BY=231) THEN GOTO 690
530 IF (BX=0 AND BH=-1) THEN GOSUB 1000: GOTO 560
540 IF (BX=255 AND BH=1) THEN GOSUB 2000: GOTO 560
550 POKE BR,BX+BH
560 POKE BR+1,BY+BV

1000 POKE ER,PEEK(ER) - B: POKE BR,255: RETURN
2000 POKE ER,PEEK(ER) + B: POKE BR,0: RETURN

Para activar o desactivar un bit de un registro se pueden utilizar dos métodos. Si conocemos si el bit está activado o desactivado podemos sumar o restar el número del sprite al valor del registro. Si sabemos que está activado podemos restar el número del sprite para desactivarlo. Si sabemos que está desactivado podemos sumar el número del sprite para activarlo. Si está activado y le sumamos el número o está desactivado y se lo restamos el resultado no será el esperado, se activarán o desactivarán otros bits.

Si no conocemos el estado del bit será necesario utilizar el operador OR para activarlo y el operador AND para desactivarlo. Con estos operadores el resultado es igual tanto si el bit está activado como desactivado. Con el operador AND debemos invertir todos los bits del número del sprite usando el operador NOT o restando a 255 el número del sprite.

Activación con OR
-----------------

POKE ER,PEEK(ER) OR B

Registro:  10100000
Sprite:    00000001
Resultado: 10100001


Desactivación con AND
---------------------

POKE ER,PEEK(ER) AND NOT B
POKE ER,PEEK(ER) AND 255-B

Registro:       10100001
Inverso Sprite: 11111110
Resultado:      10100000

En una misma línea de código se pueden ejecutar varios comandos escribiendo al final de cada comando dos puntos. Cada línea puede tener un máximo de 80 caracteres. Como el comando IF no permite ejecutar un bloque de código, una forma de poder ejecutar más comandos de los que caben en una línea es ejecutar una subrutina con el comando GOSUB. Al final de la subrutina debemos ejecutar el comando RETURN para volver a la línea desde la que se ejecutó la subrutina.

La pala solo se mueve horizontalmente. Cuando choca con el borde izquierdo cambia su dirección hacia la derecha y cuando choca con el borde derecho cambia su dirección hacia la izquierda. Para el movimiento de la pala se realizan las misma operaciones que para el movimiento horizontal de la bola. Como el ancho de la pala es distinto del ancho de la bola, las coordenadas en las que se produce el choque con los bordes son distintas a las de la bola.

460 PX=PEEK(PR)
470 PE=PEEK(ER) AND P

570 IF (PE=0 AND PX=24) THEN PH=1 
580 IF (PE=P AND PX=40) THEN PH=-1
590 IF (PX=0 AND PH=-1) THEN GOSUB 3000: GOTO 620
600 IF (PX=255 AND PH=1) THEN GOSUB 4000: GOTO 620
610 POKE PR,PX+PH

3000 POKE ER,PEEK(ER) - P: POKE PR,255: RETURN
4000 POKE ER,PEEK(ER) + P: POKE PR,0: RETURN

El chip gráfico permite detectar la colisión de los sprites con otros sprites o gráficos del fondo de la pantalla. Para ello comprueba si los puntos visibles de los sprites ocupan las mismas coordenadas que los puntos visibles de otros sprites o gráficos. En el registro 53278 se activan los bits de los sprites que han colisionado con otros sprites. En el registro 53279 se activan los bits de los sprites que han colisionado con gráficos del fondo de la pantalla.

Cada vez que se leen estos registros se desactivan sus bits, por lo que si queremos hacer varias lecturas debemos copiar su contenido a una variable y hacer las lecturas en esta variable. Para detectar que la bola ha colisionado con la pala se realiza una operación AND entre el registro 53278 y la suma de los números de la bola y la pala. En el inicio del programa se hace una lectura de este registro para limpiarlo y evitar que queden activados bits de colisiones producidas durante la asignación de coordenadas inicial o ejecuciones anteriores del programa.

240 CR=53278

350 X=PEEK(CR)

480 IF ((PEEK(CR) AND B+P) AND BV=1) THEN GOSUB 5000

Al detectarse el choque se ejecuta la subrutina de la línea 5000. En esta subrutina se cambia el movimiento vertical hacia arriba. Después de cambiar el movimiento, mientras la bola se aleja de la pala, todavía es posible que se superpongan los puntos de la bola y la pala y se detecte colisión en el registro 53278. Por eso la subrutina solo se ejecuta si además de detectarse la colisión el movimiento vertical de la bola es hacia abajo. En la subrutina también se aumenta el número de puntos y se imprime en la pantalla el nuevo número.

5010 BV=-1

5080 PO=PO+1: PRINT CHR$(19)CHR$(17)"POINTS:"PO
5100 RETURN

El registro de colisiones solo indica los sprites que han colisionado. Si tenemos más de dos sprites debemos averiguar cuales de ellos han colisionado entre si por otros métodos, por ejemplo calculando la distancia que los separa. Además tampoco podemos saber en que lugar de los sprites se ha producido la colisión. Si queremos que cuando la bola choque con la pala en las esquinas también se cambie su dirección horizontal es necesario calcular en que punto de la pala se produce el choque. Para ello primero calculamos las coordenadas totales horizontales de la bola y la pala. Si tienen activado el bit extra debemos sumar a la coordenada horizontal 256 puntos.

Una vez que tenemos las coordenadas totales podemos restarlas para hallar la distancia entre la bola y la pala. Si la distancia es positiva y mayor a 35, se ha producido el choque en la esquina derecha de la pala y el movimiento horizontal de la bola debe ser hacia la derecha. Si la distancia es negativa y menor a -11, se ha producido el choque en la esquina izquierda de la pala y el movimiento horizontal de la bola debe ser hacia la izquierda.

5020 BT=BX: PT=PX
5030 IF (BE=B) THEN BT=256+BT
5040 IF (PE=P) THEN PT=256+PT
5050 SD=BT-PT
5060 IF (SD>35) THEN BH=1
5070 IF (SD<-11) THEN BH=-1

El jugador puede cambiar la dirección de la pala mediante la pulsación de dos teclas. El comando GET permite ir leyendo las teclas pulsadas.

620 GET K$
630 IF (K$="J") THEN PH=-1
640 IF (K$="K") THEN PH=1

También se puede utilizar un joystick conectado a un puerto de control. Los joysticks constan de un mando y varios botones. El mando permite accionar uno o dos de los interruptores de dirección: arriba, abajo, izquierda y derecha. Todos los botones accionan el mismo interruptor de disparo.

En el ordenador podemos saber que interruptores están activados leyendo los primeros cinco bits de dos registros del chip CIA 1: 56321 para el puerto 1 y 56320 para el puerto 2. La lectura se hace de la misma forma que en los bits de los sprites, con la diferencia de que el valor del bit de los interruptores activados es 0.

BitInterruptorPotencia de 2Decimal
0Arriba201
1Abajo212
2Izquierda224
3Derecha238
4Botón2416
650 J1=PEEK(56321)
660 IF (J1 AND 4)=0 THEN PH=-1
670 IF (J1 AND 8)=0 THEN PH=1

En los juegos se suele añadir música o algún sonido cuando sucede algo. El ordenador Commodore 64C tiene el chip de sonido SID. Este chip tiene bastantes funcionalidades que hacen posible generar complejas melodías. Por ejemplo tiene tres voces (generadores de tonos) que permiten hacer sonar tres notas a la vez. Yo solo he utilizado una voz para generar un pequeño ruido cuando colisionan la bola y la pala.

Para generar una nota se deben configurar varios parámetros en el chip:

  • Frecuencia de la voz 1 en los registros 54272 y 54273. La frecuencia se codifica con 16 bits utilizando dos registros de 8 bits.
  • Forma de onda en el registro 54276.
  • Duración de ataque y decadencia de la voz 1 en el registro 54277.
  • Mantenimiento del nivel y duración de la liberación en el registro 54278.
  • Modo de filtro y control de volumen principal en el registro 54296.

Para que empiece a sonar la nota se activa el bit 0 del registro 54276 y para que empiece a dejar de sonar se desactiva ese mismo bit. La duración de la nota dependerá de la configuración realizada anteriormente y el tiempo transcurrido entre la activación y la desactivación. Al inicio del programa se configura el chip y cuando se produce una colisión se activa y desactiva el sonido en la subrutina de la línea 5000. La activación y desactivación de bits se realiza igual que con los bits de los sprites. Por ejemplo con el número 32 se activa el bit 5 y con el número 1 el bit 0. El bit 5 activa la forma de onda sawtooth (diente de sierra).

190 POKE 54272,50
200 POKE 54273,70
210 POKE 54277,0
220 POKE 54278,247
230 POKE 54296,15

5000 POKE 54276,32+1

5090 POKE 54276,32

EL programa se termina con el comando END. A continuación se pueden escribir las subrutinas, dejándolas fuera del código principal.

710 END

Para que el programa se ejecute a mayor velocidad lo podemos compilar a código máquina, yo he usado el compilador MOSpeed. Este programa espera que se le pase el código en letras minúsculas, si hemos escrito el programa en mayúsculas podemos utilizar el parámetro -tolower para que lo convierta a minúsculas. Con el parámetro -target podemos indicar el nombre del archivo ejecutable. El parámetro -deadstoreopt permite desactivar la eliminación de líneas con variables no utilizadas. Esto es necesario para que no se eliminen líneas como la de inicialización del generador de números aleatorios o la de limpieza del registro de colisiones. El ejecutable resultante lo podemos utilizar en un ordenador real o un emulador como VICE.

# wget https://github.com/EgonOlsen71/basicv2/raw/master/dist/basicv2.jar

# java -cp basicv2.jar com.sixtyfour.cbmnative.shell.MoSpeedCL ball-and-paddle.bas -tolower=true -deadstoreopt=false -target=ball-and-paddle.prg

# x64sc ball-and-paddle.prg

Esto es lo básico que necesitamos saber para programar un juego. En la documentación se pueden ver más comandos de BASIC. En próximos artículos analizaré con más detalle el uso del chip de sonido SID.

No hay comentarios:

Publicar un comentario