Ensamblador para ZX Spectrum – Pong: $0A Partida a dos jugadores y cambio de velocidad de la bola
Búscate a un amigo, que vamos a empezar a jugar.

Ensamblador para ZX Spectrum – Pong: Paso 8, partida a dos jugadores y cambio de velocidad de la bola.
En este paso vamos a implementar la partida a dos jugadores, con marcador, y la posibilidad de cambiar la velocidad de la bola.
Creamos la carpeta Paso08 y copiamos los archivos Controls.asm, Game.asm, Main.asm, Sprite.asm y Video.asm desde la carpeta Paso07.
Marcador
Vamos a empezar por el marcador, definiendo la posición dónde vamos a pintar la puntuación, y definiendo también los sprites necesarios en el archivo Sprite.asm.
POINTS_P1: EQU $450d POINTS_P2: EQU $4511
Cada dígito de los marcadores ocupa 8×16 píxeles, o lo que es lo mismo, 1 carácter de ancho por 2 de alto (1 byte x 16 bytes/scanlines):
Blanco_sprite: ds $10 ; 16 espacios = 16 bytes a $00 Cero_sprite: db $00, $7e, $7e, $66, $66, $66, $66, $66 db $66, $66, $66, $66, $66, $7e, $7e, $00 Uno_sprite: db $00, $18, $18, $18, $18, $18, $18, $18 db $18, $18, $18, $18, $18, $18, $18, $00 Dos_sprite: db $00, $7e, $7e, $06, $06, $06, $06, $7e db $7e, $60, $60, $60, $60, $7e, $7e, $00 Tres_sprite: db $00, $7e, $7e, $06, $06, $06, $06, $3e db $3e, $06, $06, $06, $06, $7e, $7e, $00 Cuatro_sprite: db $00, $66, $66, $66, $66, $66, $66, $7e db $7e, $06, $06, $06, $06, $06, $06, $00 Cinco_sprite: db $00, $7e, $7e, $60, $60, $60, $60, $7e db $7e, $06, $06, $06, $06, $7e, $7e, $00 Seis_sprite: db $00, $7e, $7e, $60, $60, $60, $60, $7e db $7e, $66, $66, $66, $66, $7e, $7e, $00 Siete_sprite: db $00, $7e, $7e, $06, $06, $06, $06, $06 db $06, $06, $06, $06, $06, $06, $06, $00 Ocho_sprite: db $00, $7e, $7e, $66, $66, $66, $66, $7e db $7e, $66, $66, $66, $66, $7e, $7e, $00 Nueve_sprite: db $00, $7e, $7e, $66, $66, $66, $66, $7e db $7e, $06, $06, $06, $06, $7e, $7e, $00
Una vez que hemos definido los sprites, definimos la composición de los números, haciendo referencia a las etiquetas de los sprites:
Cero: dw Blanco_sprite, Cero_sprite Uno: dw Blanco_sprite, Uno_sprite Dos: dw Blanco_sprite, Dos_sprite Tres: dw Blanco_sprite, Tres_sprite Cuatro: dw Blanco_sprite, Cuatro_sprite Cinco: dw Blanco_sprite, Cinco_sprite Seis: dw Blanco_sprite, Seis_sprite Siete: dw Blanco_sprite, Siete_sprite Ocho: dw Blanco_sprite, Ocho_sprite Nueve: dw Blanco_sprite, Nueve_sprite Diez: dw Uno_sprite, Cero_sprite Once: dw Uno_sprite, Uno_sprite Doce: dw Uno_sprite, Dos_sprite Trece: dw Uno_sprite, Tres_sprite Catorce: dw Uno_sprite, Cuatro_sprite Quince: dw Uno_sprite, Cinco_sprite
Ahora necesitamos definir el lugar donde vamos a guardar la puntuación de cada jugador. Abrimos el archivo Main.asm y añadimos las siguientes variables antes de END $8000:
p1points: db $00 p2points: db $00
Ya tenemos todo listo para empezar a implementar el marcador.
Lo primero que tenemos que saber es que sprite tenemos que pintar, dependiendo del marcador de cada jugador. Para saber que sprite pintar, vamos a implementar una rutina que recibe en A la puntuación, y devuelve en HL la dirección del sprite a pintar.
Abrimos el archivo Video.asm e implementamos justo antes de la rutina NextScan:
GetPointSprite: ld hl, Cero ld bc, $04 inc a
Cargamos en HL la dirección del sprite para el cero, LD HL, Cero. Como cada sprite está a 4 bytes del anterior, cargamos este desplazamiento en BC, LD BC, $04, e incrementamos A para que el bucle no empiece en 0, INC A, en el caso de que la puntuación sea 0.
Ahora hacemos un bucle para que HL apunte al sprite correcto:
getPointSprite_loop: dec a ret z add hl, bc jr getPointSprite_loop
Decrementamos A, DEC A, y si hemos llegado a 0, HL ya apunta al sprite correcto y salimos, RET Z. Si todavía no hemos llegado a 0, sumamos el desplazamiento a HL, ADD HL, BC, y volvemos a ejecutar el bucle, JR getPointSprite_loop.
El aspecto final de la rutina es:
; – --------------------------------------------------------------------------- ; Obtiene el sprite correspondiente a pintar en el marcador. ; Entrada: A -> puntuación. ; Salida: HL -> Dirección del sprite a pintar. ; Altera el valor de los registros AF, BC y HL. ; – --------------------------------------------------------------------------- GetPointSprite: ld hl, Cero ; Carga en HL la dirección del sprite del 0 ld bc, $04 ; Cada sprite está del anterior a 4 bytes inc a ; Incrementa A para que el inicio del bucle no sea 0 getPointSprite_loop: dec a ; Decrementa A ret z ; Si ha llegado a 0, fin de rutina add hl, bc ; Suma 4 a la dirección del sprite; siguiente sprite jr getPointSprite_loop ; Bucle hasta que A = 0 ret
Y ahora vamos a implementar la rutina que pinta los marcadores, al final del archivo Video.asm:
PrintPoints: ld a, (p1points) call GetPointSprite
Cargamos la puntuación del jugador 1 en A, LD A, (p1points), y obtenemos la dirección de memoria donde está la definición del sprite correspondiente a dicha puntuación, CALL GetPointSprite.
GetPointSprite nos devuelve en HL la dirección de memoria donde está definido el sprite. Si la puntuación es cero, HL nos traerá la dirección de memoria donde está definida la etiqueta Cero, cuya definición es la siguiente:
Cero: dw Blanco_sprite, Cero_sprite
Como podemos ver, Cero está definido por otras dos direcciones de memoria; la primera es la dirección de memoria donde está definido el sprite blanco, usado para justificar a dos dígitos, y la segunda es la dirección de memoria donde está definido el sprite cero.
Si las direcciones de memoria fueran las siguientes:
$9000 Blanco_sprite $9020 Cero_sprite $9040 Cero
La definición de la etiqueta Cero, una vez que se sustituyen las etiquetas Blanco_sprite y Cero_sprite por las direcciones de memoria donde están definidas, sería:
Cero: dw $9000, $9020
El valor que tendría HL tras llamar a GetPointSprite con el marcador a 0 sería $9040, o lo que es lo mismo, la dirección de memoria dónde se define la etiqueta Cero.
Como el Z80 es Little Endian, los valores de las direcciones de memoria desde $9040 en adelante serían:
$9040 | $00 |
$9041 | $90 |
$9042 | $20 |
$9043 | $90 |
O lo que es lo mismo, las direcciones de memoria donde están definidos los sprites para Blanco_sprite y para Cero_sprite.
Esta explicación es necesaria para entender el funcionamiento del resto de la rutina:
push hl ld e, (hl) inc hl ld d, (hl) ld hl, POINTS_P1 call printPoint_print
Vamos a pintar el primer dígito del marcador del jugador 1. Preservamos el valor de HL, que apunta al sprite del marcador que tenemos que pintar, PUSH HL, cargamos en E la parte baja de la dirección donde está el sprite del primer dígito, LD E, (HL), apuntamos HL a la parte alta de la dirección, INC HL, y la cargamos en D, LD D, (HL).
Cargamos en HL la dirección de memoria de pantalla donde se pinta el primer dígito del marcador del jugador 1, LD HL, POINTS_P1, y llamamos al pintado del dígito, CALL printPoint_print.
Ahora pintamos el segundo dígito del marcador del jugador 1:
pop hl inc hl inc hl
Recuperamos el valor de HL, POP HL, y lo apuntamos a la parte baja de la dirección donde está definido el sprite del segundo dígito, INC HL, INC HL.
ld e, (hl) inc hl ld d, (hl)
Cargamos la parte baja de dicha dirección en E, LD E, (HL), apuntamos HL a la parte alta de la dirección, INC HL, y la cargamos en D, LD D, (HL).
ld hl, POINTS_P1 inc l call printPoint_print
Por último, cargamos en HL la posición de memoria de la pantalla dónde se pinta el marcador del jugador 1, LD HL, POINTS_P1. Como cada dígito ocupa 1 byte (columna) de ancho, situamos HL en la columna dónde se pinta el segundo dígito, INC L, y lo pintamos, CALL printPoint_print.
La forma de pintar el marcador del jugador 2 es casi igual a la del jugador 1, por lo que mostramos el código marcando los cambios y sin entrar en detalle:
ld a, (p2points) ; # Cambio # call GetPointSprite push hl ; 1er dígito ld e, (hl) inc hl ld d, (hl) ld hl, POINTS_P2 ; # Cambio # call printPoint_print pop hl ; 2º dígito inc hl inc hl ld e, (hl) inc hl ld d, (hl) ld hl, POINTS_P2 ; # Cambio # inc l
Como se puede observar, los cambios son pocos. Se ha quitado la última línea al no ser necesario llamar a pintar el segundo dígito del jugador 2, ya que lo vamos a implementar a continuación del último INC L.
Recordemos que cada dígito ocupa 8×16 píxeles (1 columna x 16 scanlines):
printPoint_print: ld b, $10 push de push hl
Cargamos en B el número de scanlines que vamos a pintar, LD B, $10, y preservamos el valor del registro DE, PUSH DE, y de HL, PUSH HL.
printPoint_printLoop: ld a, (de) ld (hl), a inc de call NextScan djnz printPoint_printLoop
Cargamos en A el byte a pintar, LD A, (DE), y lo pintamos en pantalla, LD (HL), A. Apuntamos DE al siguiente byte a pintar, INC DE, obtenemos la dirección del siguiente scanline, CALL NextScan, y repetimos la operación hasta que B sea 0 y hayamos pintado los 16 scanlines, DJNZ printPoint_printLoop.
Para finalizar, recuperamos los valores de HL y DE y salimos:
pop hl pop de ret
El aspecto final de la rutina de pintado del marcador es el siguiente:
; – --------------------------------------------------------------------------- ; Pinta el marcador. ; Cada número consta de 1 byte de ancho por 16 de alto. ; Altera el valor de los registros AF, BC, DE y HL. ; – --------------------------------------------------------------------------- PrintPoints: ld a, (p1points) ; Carga en A los puntos del jugador 1 call GetPointSprite ; Obtiene el sprite a pintar en el marcador push hl ; Preserva el valor de HL ; 1er dígito del jugador 1 ld e, (hl) ; Carga en E la parte baja de la dirección ; donde está el primer dígito inc hl ; Apunta HL a la parte alta de la dirección ; donde está el primer dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P1 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 1 call printPoint_print ; Pinta el primer dígito del marcador del jugador 1 pop hl ; Recupera el valor de HL ; 2º dígito del jugador 1 inc hl inc hl ; Apunta HL a la parte baja de la dirección ; donde está el segundo dígito ld e, (hl) ; y la carga en E inc hl ; Apunta HL a la parte alta de la dirección ; donde está el segundo dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P1 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 1 inc l ; Apunta HL a la dirección donde se pinta el segundo dígito call printPoint_print ; Pinta el segundo dígito del marcador del jugador 1 ld a, (p2points) ; Carga en A los puntos del jugador 2 call GetPointSprite ; Obtiene el sprite a pintar en el marcador push hl ; Preserva el valor de HL ; 1er dígito del jugador 2 ld e, (hl) ; Carga en E la parte baja de la dirección ; donde está el primer dígito inc hl ; Apunta HL a la parte alta de la dirección ; donde está el primer dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P2 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 2 call printPoint_print ; Pinta el primer dígito del marcador del jugador 2 pop hl ; Recupera el valor de HL ; 2º dígito del jugador 2 inc hl inc hl ; Apunta HL a la parte baja de la dirección ; donde está el segundo dígito ld e, (hl) ; y la carga en E inc hl ; Apunta HL a la parte alta de la dirección ; donde está el segundo dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P2 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 2 inc l ; Apunta HL a la dirección donde se pinta el segundo dígito ; Pinta el segundo dígito del marcador del jugador 2 printPoint_print: ld b, $10 ; Cada dígito son 1 byte por 16 (scanlines) push de ; Preserva el valor de DE push hl ; Preserva el valor de HL printPoint_printLoop: ld a, (de) ; Carga en A el byte a pintar ld (hl), a ; Pinta el byte inc de ; Apunta DE al siguiente byte call NextScan ; Apunta HL al siguiente scanline djnz printPoint_printLoop ; Hasta que B = 0 pop hl ; Recupera el valor de HL pop de ; Recupera el valor de DE ret
En esta rutina es sencillo ahorrar 12 ciclos de reloj y 2 bytes. Para ello hay que cambiar dos instrucciones de lugar, lo que nos permite quitar otras dos; lo veremos en la última entrega.
Y ahora solo nos queda ver si lo que hemos implementado funciona.
Abrimos Main.asm, y debajo de la llamada a PrintBorder, justo antes de Loop, añadimos la siguiente línea:
call PrintPoints
Compilamos y cargamos en el emulador para ver los resultados:

En principio todo va bien, pero según se va moviendo la bola vemos que volvemos a tener un problema, viejo conocido nuestro, y es que la bola borra el marcador a su paso, cosa que vamos a solucionar a continuación.
Para evitar que la bola borre el marcador, hacemos lo mismo que hicimos con la línea central, vamos a repintar el marcador.
Implementamos la rutina al final del archivo Video.asm.
En realidad, la rutina de repintado del marcador es prácticamente igual que la de pintado, cambiando el nombre de las etiquetas y añadiendo una línea.
Vamos a copiar la rutina de pintado del marcador y la vamos a pegar al final del archivo Video.asm. Cambiamos los nombres de las etiquetas y añadimos una línea.
A continuación, mostramos el aspecto final de la rutina:
; – --------------------------------------------------------------------------- ; Repinta el marcador. ; Cada número consta de 4 bytes de ancho por 16 de alto = 64 bytes. ; Altera el valor de los registros AF, BC, DE y HL. ; – --------------------------------------------------------------------------- ReprintPoints: ld a, (p1points) ; Carga en A los puntos del jugador 1 call GetPointSprite ; Obtiene el sprite a pintar en el marcador push hl ; Preserva el valor de HL ; 1er dígito del jugador 1 ld e, (hl) ; Carga en E la parte baja de la dirección ; donde está el primer dígito inc hl ; Apunta HL a la parte alta de la dirección ; donde está el primer dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P1 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 1 call reprintPoint_print ; Pinta el primer dígito del marcador del jugador 1 pop hl ; Recupera el valor de HL ; 2º dígito del jugador 1 inc hl inc hl ; Apunta HL a la parte baja de la dirección ; donde está el segundo dígito ld e, (hl) ; y la carga en E inc hl ; Apunta HL a la parte alta de la dirección ; donde está el segundo dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P1 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 1 inc l ; Apunta HL a la dirección donde se pinta el segundo dígito call reprintPoint_print ; Pinta el segundo dígito del marcador del jugador 1 ld a, (p2points) ; Carga en A los puntos del jugador 2 call GetPointSprite ; Obtiene el sprite a pintar en el marcador push hl ; Preserva el valor de HL ; 1er dígito del jugador 2 ld e, (hl) ; Carga en E la parte baja de la dirección ; donde está el primer dígito inc hl ; Apunta HL a la parte alta de la dirección ; donde está el primer dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P2 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 2 call reprintPoint_print ; Pinta el primer dígito del marcador del jugador 2 pop hl ; Recupera el valor de HL ; 2º dígito del jugador 2 inc hl inc hl ; Apunta HL a la parte baja de la dirección ; donde está el segundo dígito ld e, (hl) ; y la carga en E inc hl ; Apunta HL a la parte alta de la dirección ; donde está el segundo dígito ld d, (hl) ; y la carga en D ld hl, POINTS_P2 ; Carga en HL la dirección de memoria donde se pintan ; los puntos del jugador 2 inc l ; Apunta HL a la dirección donde se pinta el segundo dígito ; Pinta el segundo dígito del marcador del jugador 2 reprintPoint_print: ld b, $10 ; Cada dígito es de 1 byte por 16 (scanlines) push de push hl ; Preserva el valor de los registros DE y HL reprintPoint_printLoop: ld a, (de) ; Carga en A el byte a pintar or (hl) ; Lo mezcla con lo que hay pintado en pantalla ld (hl), a ; Pinta el byte inc de ; Apunta DE al siguiente byte call NextScan ; Apunta HL al siguiente scanline djnz reprintPoint_printLoop ; Hasta que B = 0 pop hl pop de ; Recupera el valor de los registros HL y DE ret
Vamos a explicar la línea que hemos añadido (línea 67):
ld a, (de) or (hl) ld (hl), a
Lo que hacemos con OR (HL) es agregar los píxeles que hay en pantalla a los píxeles del sprite del número. De esta manera repintamos el número sin borrar la bola.
Ahora queda ver si funciona. Abrimos el archivo Main.asm y añadimos la siguiente línea después de la llamada a ReprintLine:
call ReprintPoints
Compilamos y cargamos en el emulador para ver los resultados:

Efectivamente, hemos solucionado un problema, pero ha surgido otro. El marcador ya no se borra, pero la bola va muy lenta. Por suerte la solución es sencilla, ya que la velocidad de la bola es una de las cosas que controlamos nosotros.
Como recordaréis, la bola se mueve 1 de cada 6 iteraciones del bucle principal, por lo que lo único que tenemos que hacer es reducir este intervalo en Main.asm, por ejemplo a 2:
ld (countLoopBall), a cp $02 ; # Cambiamos el intervalo # jr nz, loop_paddle
Compilamos, cargamos en el emulador y comprobamos que la velocidad de la bola ha aumentado.
Cambio de velocidad de la bola
Como recordaremos, en la variable ballSetting definimos la velocidad de la bola en los bits 4 y 5, pudiendo ser 1 la más rápida y 3 la más lenta.
Vamos a utilizar este aspecto para definir y modificar la velocidad de la bola.
Lo primero es modificar el valor inicial de esta variable:
ballSetting: db $20
De esta manera el valor inicial es:
- Dirección vertical: arriba.
- Dirección horizontal: derecha.
- Velocidad: 2.
Y ahora vamos a usar este valor para controlar el intervalo para mover la bola. Abrimos Main.asm, localizamos la etiqueta Loop, y añadimos justo debajo:
ld a, (ballSetting) rrca rrca rrca rrca and $03 ld b, a
Cargamos la configuración de la bola en A, LD A, (ballSetting), pasamos el valor de los bits 4 y 5 a los bits 0 y 1, RRCA, RRCA, RRCA, RRCA, nos quedamos con el valor de los bits 0 y 1 (velocidad de la bola), AND $03, y cargamos el valor en B, LD B, A.
Cuatro líneas más abajo, cambiamos la línea CP $02:
cp b
Compilamos y comprobamos que todo sigue funcionando igual. La única diferencia es que ahora la velocidad de la bola la tomamos desde la configuración de la misma, y podremos cambiarla.
Para cambiar la velocidad de la bola, vamos a usar las teclas del 1 al 3. Abrimos el archivo Controls.asm y empezamos a escribir tras la etiqueta ScanKeys:
scanKeys_speed: ld a, $00 ld (countLoopBall), a scanKeys_ctrl:
Si se ha pulsado algunas de las teclas de cambio de velocidad, hay que poner a 0 el contador de vueltas de bucle para pintar la bola, de lo contrario, si el contador está en 2 y ponemos las velocidad a 1, habrá que esperar 254 iteraciones hasta que la bola se vuelva a mover.
Ponemos A = 0, LD A, $00, y ponemos el contador de iteraciones para la bola a 0, LD (countLoopBall), A.
La etiqueta scanKeys_ctrl marca el punto donde empieza la rutina tal y como la tenemos ahora. La nueva implementación la vamos a hacer entre las etiquetas ScanKeys y scanKeys_speed:
ld a, $f7 in a, ($fe) ld d, a bit $00, d jr nz, scanKeys_2
Cargamos la semifila 1-5 en A, LD A, $F7, leemos el puerto del teclado, IN A, ($FE), y cargamos el resultado en D, LD D, A.
bit $00, d jr nz, scanKeys_2
Comprobamos si se ha pulsado el 1, BIT $00, D, y en caso de no haberlo pulsado saltamos a comprobar si se ha pulsado el 2, JR NZ, scanKeys_2.
Si se ha pulsado el 1, cambiamos la velocidad de la bola:
ld a, (ballSetting) and $cf or $10 ld (ballSetting), a jr scanKeys_speed
Cargamos la configuración de la bola en A, LD A, (ballSetting), ponemos los bits de la velocidad a 0, AND $CF, ponemos la velocidad a 1, OR $10, cargamos la configuración en memoria, LD (ballSetting), A, y saltamos a poner a 0 el contador de iteraciones para la bola, JR scanKeys_speed.
La comprobación para el 2 y el 3 es muy parecida a la comprobación del 1, por lo que vemos el código completo y marcamos las diferencias:
scanKeys_2: bit $01, d ; # Cambio # jr nz, scanKeys_3 ; # Cambio # ld a, (ballSetting) and $cf or $20 ; # Cambio # ld (ballSetting), a jr scanKeys_speed scanKeys_3: bit $02, d ; # Cambio # jr nz, scanKeys_speed ; # Cambio # ld a, (ballSetting) ; and $cf ; # Eliminar línea # or $30 ; # Cambio # ld (ballSetting), a
El aspecto final de la rutina, una vez modificada, queda de la siguiente manera:
; – --------------------------------------------------------------------------- ; ScanKeys ; Escanea las teclas de control y devuelve las pulsadas. ; Salida: D -> Teclas pulsadas. ; Bit 0 -> A pulsada 0/1. ; Bit 1 -> Z pulsada 0/1. ; Bit 2 -> 0 pulsada 0/1. ; Bit 3 -> O pulsada 0/1. ; Altera el valor de los registros AF y D. ; – --------------------------------------------------------------------------- ScanKeys: ld a, $f7 ; Carga en A la semifila 1-5 in a, ($fe) ; Lee el estado de la semifila bit $00, a ; Comprueba si se ha pulsado el 1 jr nz, scanKeys_2 ; Si no se ha pulsado salta ; Se ha pulsado; cambia la velocidad de la bola 1 (rápido) ld a, (ballSetting) ; Carga la configuración de la bola en A and $cf ; Pone los bits de velocidad a 0 or $10 ; Pone los bits de velocidad a 1 ld (ballSetting), a ; Carga el valor en memoria jr scanKeys_speed ; Salta para comprobar los controles scanKeys_2: bit $01, a ; Comprueba si se ha pulsado el 2 jr nz, scanKeys_3 ; Si no se ha pulsado salta ; Se ha pulsado; cambia la velocidad de la bola 2 (medio) ld a, (ballSetting) ; Carga la configuración de la bola en A and $cf ; Pone los bits de velocidad a 0 or $20 ; Pone los bits de velocidad a 2 ld (ballSetting), a ; Carga el valor en memoria jr scanKeys_speed ; Salta para comprobar los controles scanKeys_3: bit $02, a ; Comprueba si se ha pulsado el 3 jr nz, scanKeys_ctrl ; Si no se ha pulsado salta ; Se ha pulsado; cambia la velocidad de la bola 3 (lento) ld a, (ballSetting) ; Carga la configuración de la bola en A or $30 ; Pone los bits de velocidad a 3 ld (ballSetting), a ; Carga el valor en memoria scanKeys_speed: ld a, $00 ; Pone A = 0 ld (countLoopBall), a ; Pone el contador de iteraciones para la bola a 0 scanKeys_ctrl: ld d, $00 ; Pone el registro D a 0. ; Resto de la rutina desde ScanKeys_A
Es el momento de compilar y cargar en el emulador para comprobar cómo se comporta esta modificación. Si todo ha ido bien, podemos cambiar la velocidad de la bola.
Empezamos la partida
Lo último que tenemos que hacer es contabilizar los puntos de cada jugador, para lo cual vamos a modificar la rutina MoveBall, en concreto moveBall_rightChg y movelBall_leftChg.
Estas rutinas se encargan de cambiar la dirección de la bola cuando llega al límite izquierdo o derecho. Vamos a implementar lo necesario para que marque los puntos.
El código nuevo lo vamos a poner justo debajo de dichas etiquetas, empezando por moveBall_rightChg:
moveBall_rightChg: ld hl, p1points inc (hl) call PrintPoints
Cargamos en HL la dirección de memoria donde se encuentra el marcador del jugador 1, LD HL, p1points, lo incrementamos, INC (HL), y pintamos el marcador, CALL PrintPoints. El resto de la rutina se queda como estaba.
Las modificaciones en la etiqueta movelBall_leftChg son prácticamente las mismas:
moveBall_leftChg: ld hl, p2points inc (hl) call PrintPoints
Compilamos y cargamos en el emulador para ver los resultados:

Ya tenemos marcador, pero la partida continúa interminablemente y cuando pasamos de 15 puntos, empieza a pintar cosas sin sentido.

También podemos apreciar que cada vez va más lento. ¿Pero por qué? Pintamos el marcador en cada iteración, y para localizar el sprite del número a pintar hacemos un bucle, y no es lo mismo un bucle con 15 iteraciones como máximo, que un bucle con hasta 255 iteraciones. ¿A qué no?
Fin de la partida
Lo que tenemos que hacer ahora es parar la partida cuando alguno de los dos jugadores llegue a 15 puntos; de igual manera vamos a implementar un modo de iniciar la partida, por ejemplo, pulsando el 5.
Al final del archivo Controls.asm, vamos a implementar la rutina que espere a que se pulse el 5 para iniciar la partida:
WaitStart: ld a, $f7 in a, ($fe) bit $04, a jr nz, WaitStart ret
Cargamos en A la semifila 1-5, LD A, $F7, leemos el teclado, IN A, ($FE), evaluamos si se ha pulsado el 5, BIT $04, A, y repetimos la operación hasta que se pulse, JR NZ, WaitStart.
El aspecto final de la rutina es:
; – --------------------------------------------------------------------------- ; WaitStart. ; Espera que se pulse la tecla 5 para empezar la partida. ; Altera el valor de los registros AF. ; – --------------------------------------------------------------------------- WaitStart: ld a, $f7 ; Carga en A la semifila 1-5 in a, ($fe) ; Lee el teclado bit $04, a ; Evalúa si se ha pulsado el 5 jr nz, WaitStart ; Bucle hasta que se pulse el 5 ret
Volvemos a Main.asm y después de la llamada a PrintPoints, ponemos la siguiente línea:
call WaitStart
Si compilamos y cargamos en el emulador, hasta que no pulsemos el 5, no empezaremos la partida.
Pero con esto no es suficiente ya que la partida no finaliza cuando uno de los jugadores llega a 15 puntos.
Seguimos en Main.asm, pero esta vez al final de la rutina loop_continue, justo antes de JR Loop. Es aquí donde vamos a implementar el control de la puntuación:
ld a, (p1points) cp $0f jr z, Main
Cargamos la puntuación del jugador 1 en A, LD A, (p1points), la comparamos con 15, CP $0F, y si es quince saltamos al inicio del programa, JR Z, Main.
Hacemos los mismo con la puntuación del jugador 2:
ld a, (p2points) cp $0f jr z, Main
Compilamos, cargamos en el emulador y comprobamos que cuando uno de los dos jugadores llega a 15 puntos, la partida finaliza:

¿Pero qué pasa si volvemos a pulsar el 5? Ya no hay forma de iniciar la partida. En ningún momento ponemos el marcador a 0. Si dejamos pulsado el 5, veremos como a cada iteración del bucle, vuelve al inicio y se para.
Para solucionar esto, volvemos al inicio del archivo Main.asm, y justo después de la llamada a WaitStart, vamos a poner los marcadores a 0:
ld a, ZERO ld (p1points), a ld (p2points), a call PrintPoints
Ponemos A = 0, LD A, ZERO, ponemos la puntuación del jugador 1 a 0, LD (p1points), A, ponemos la puntuación del jugador 2 a 0, LD (p2points), A, y pintamos el marcador, CALL PrintPoints. De esta manera, cada vez que iniciamos partida, ponemos los marcadores a 0 y los pintamos.
Compilamos y cargamos en el emulador para ver los resultados. Esto empieza a tomar forma.
Todavía nos quedan ajustes por realizar. Vamos a hacer que cuando se marque un tanto, la bola salga por el lado contrario, es decir, como si sacara el jugador que ha marcado.
Vamos a implementar una rutina para borrar la bola, otra para situarla en la parte derecha de la pantalla, y otra para situarla en la parte izquierda.
La rutina para borrar la bola la vamos a implementar en el archivo Video.asm, justo antes de la rutina Cls:
ClearBall: ld hl, (ballPos) ld a, l and $1f cp $10 jr c, clearBall_continue inc l
Cargamos la posición de la bola en HL, LD HL, (ballPos), cargamos la fila y la columna en A, LD A, L, nos quedamos con la columna, AND $1F, y lo comparamos con el centro de la pantalla, CP $10.
Si hay acarreo, solo puede estar en el margen izquierdo. Saltamos a borrar la bola, JR C, clearBall_continue. Si no salta, está en el margen derecho, pero la bola en realidad está pintada una columna más a la derecha (la bola se pinta en dos bytes/columnas); apuntamos HL a la columna dónde está pintada la bola, INC L.
clearBall_continue: ld b, $06 clearBall_loop: ld (hl), ZERO call NextScan djnz clearBall_loop ret
Cargamos en B el número de scanlines que vamos a borrar, LD B, $06, borramos la posición apuntada por HL, LD (HL), ZERO, apuntamos HL al siguiente scanline, CALL NextScan, repetimos la operación hasta que B valga 0, DJNZ clearBall_loop, y salimos, RET.
El aspecto final de la rutina es el siguiente:
; – --------------------------------------------------------------------------- ; Borra la bola. ; Altera el valor de los registros AF, B y HL. ; – --------------------------------------------------------------------------- ClearBall: ld hl, (ballPos) ; Carga la posición de la bola en HL ld a, l ; Carga la fila y columna en A and $1f ; Se queda con la columna cp $10 ; Lo compara con el centro de la pantalla jr c, clearBall_continue ; Si está a la izquierda salta inc l ; Incrementa la columna clearBall_continue: ld b, $06 ; Bucle por 6 scanlines clearBall_loop: ld (hl), ZERO ; Borra el byte apuntado por HL call NextScan ; Obtiene el scanline siguiente djnz clearBall_loop ; Hasta que B = 0 ret
Las otras dos rutinas las vamos a implementar al final del archivo Game.asm:
SetBallLeft: ld hl, $4d60 ld (ballPos), hl ld a, $01 ld (ballRotation), a ld a, (ballSetting) and $bf ld (ballSetting), a ret
Cargamos en HL la nueva posición de la bola, LD HL, $4D60, y lo cargamos en memoria, LD (ballPos), HL.
Cargamos la rotación de la bola en A, LD A, $01, y lo cargamos en memoria, LD (ballRotation), A.
Cargamos la configuración de la bola en A, LD A, (ballSetting), ponemos la dirección horizontal hacia la derecha, AND $BF, lo cargamos en memoria, LD (ballSetting), A, y salimos, RET.
La rutina para posicionar la bola a la derecha es prácticamente igual; marcamos las diferencias sin entrar en detalle:
SetBallRight: ; # Cambio # ld hl, $4d7e ; # Cambio # ld (ballPos), hl ld a, $ff ; # Cambio # ld (ballRotation), a ld a, (ballSetting) or $40 ; # Cambio # ld (ballSetting), a ret
El aspecto final de las dos rutinas es el siguiente:
; – --------------------------------------------------------------------------- ; Posiciona la bola a la izquierda. ; Altera el valor de los registros AF y HL. ; – --------------------------------------------------------------------------- SetBallLeft: ld hl, $4d60 ; Carga en HL la posición de la bola ld (ballPos), hl ; Carga el valor en memoria ld a, $01 ; Carga 1 en A ld (ballRotation), a ; Lo carga en memoria, Rotación = 1 ld a, (ballSetting) ; Carga en A la dirección y velocidad de la bola and $bf ; Pone la dirección horizontal hacia la derecha ld (ballSetting), a ; Carga la nueva dirección de la bola en memoria ret ; – --------------------------------------------------------------------------- ; Posiciona la bola a la derecha. ; Altera el valor de los registros AF y HL. ; – --------------------------------------------------------------------------- SetBallRight: ld hl, $4d7e ; Carga en HL la posición de la bola ld (ballPos), hl ; Carga el valor en memoria ld a, $ff ; Carga -1 en A ld (ballRotation), a ; Lo carga en memoria, Rotación = -1 ld a, (ballSetting) ; Carga en A la dirección y velocidad de la bola or $40 ; Pone la dirección horizontal hacia la izquierda ld (ballSetting), a ; Carga la nueva dirección de la bola en memoria ret
Para acabar con este paso, solo nos queda utilizar estas rutinas.
Vamos a modificar las rutinas moveBall_rightChg y moveBall_leftChg del archivo Game.asm.
En la rutina moveBall_right, borramos las líneas que hay entre CALL PrintPoints y JR moveBall_end, y las sustituimos por:
call ClearBall call SetBallLeft
El aspecto final de la rutina es el siguiente:
moveBall_rightChg: ; Ha llegado al límite derecho, ¡PUNTO! ld hl, p1points ; Carga en HL la dirección de la puntuación del jugador 1 inc (hl) ; Lo incrementa call PrintPoints ; Pinta el marcador call ClearBall ; Borra la bola call SetBallLeft ; Pone la bola a la izquierda jr moveBall_end ; Fin de la rutina
En la rutina moveBall_leftChg, borramos las líneas que hay entre CALL PrintPoints y la etiqueta moveBall_end, y las sustituimos por:
call ClearBall call SetBallRight
El aspecto final de la rutina es el siguiente:
moveBall_leftChg: ; Ha llegado al límite izquierdo, ¡PUNTO! ld hl, p2points ; Carga en HL la dirección de la puntuación del jugador 2 inc (hl) ; Lo incrementa call PrintPoints ; Pinta el marcador call ClearBall ; Borra la bola call SetBallRight ; Pone la bola a la derecha
Compilamos, cargamos en el emulador, y ya podemos empezar a jugar nuestras primeras partidas a dos jugadores, aunque todavía quedan cosas por hacer.
En el próximo capítulo de Ensamblador para ZX Spectrum, implementaremos el cambio de dirección y velocidad de la bola al golpear la pala, entrando de esta manera en la recta final.
Enlaces de interés
- Notepad++.
- Visual Studio Code.
- Sublime Text.
- ZEsarUX.
- PASMO.
- Git.
- Curso de ensamblador Z80 de Compiler Software.
- Z80 instruction set.