0x0B Ensamblador ZX Spectrum Marciano – Comportamiento de los enemigos
En este capítulo de Ensamblador ZX Spectrum Marciano, nos vamos a centrar en el comportamiento de los enemigos.
Aunque al inicio del tutorial comenté que el desarrollo estaba hecho y que lo único que iba a hacer es tutorizarlo, la realidad es que según he ido revisando el código, he ido cambiando cosas con respecto del original, y una de ellas es en lo relativo al comportamiento de los enemigos.
Creamos la carpeta Paso11 y copiamos desde la carpeta Paso10 los archivos Cargador.tap, Const.asm, Ctrl.asm, Game.asm, Graph.asm, Int.asm, Main.asm, make o make.bat, Print.asm y Var.asm.
Tabla de contenidos
- Cambios de dirección
- Cambio de color
- Disparos enemigos
- Ajuste de la dificultad
- Ensamblador ZX Spectrum, conclusión
- Enlaces de interés
Cambios de dirección
Para dar la sensación de que el movimiento de los enemigos es algo menos previsible, vamos a hacer que cada cuatro segundos se cambie la dirección de los mismos. En el programa original usaba una rutina que generaba números pseudoaleatorios, cosa que voy a simplificar en esa ocasión.
Lo primero que vamos a hacer es abrir el archivo Main.asm, localizamos la etiqueta flags al inicio del mismo, y añadimos un comentario para el bit tres.
; -----------------------------------------------------------------------------
; Indicadores
;
; Bit 0 -> se debe mover la nave 0 = No, 1 = Sí
; Bit 1 -> el disparo está activo 0 = No, 1 = Sí
; Bit 2 -> se deben mover los enemigos 0 = No, 1 = Sí
; Bit 3 -> cambia dirección enemigos 0 = No, 1 = Sí
; -----------------------------------------------------------------------------
flags:
db $00
Cada cuatro segundos, activaremos el bit tres y se cambiará la dirección de los enemigos.
Para que el cambio de dirección de los enemigos no sea siempre el mismo, vamos a utilizar una etiqueta auxiliar; abrimos el archivo Var.asm, localizamos la etiqueta extraCounter y añadimos las líneas siguientes:
; -----------------------------------------------------------------------------
; Valores auxiliares
; -----------------------------------------------------------------------------
swEnemies:
db $00
enemiesColor:
db $06
La etiqueta que vamos a usar es swEnemies. Como podéis ver, he añadido otra etiqueta más, que vamos a usar para añadir un pequeño efecto de color a los enemigos.
Ahora vamos a implementar la rutina que va a realizar el cambo de dirección de los enemigos. Abrimos el archivo Game.asm, e implementamos al principio la rutina que cambia la dirección de los enemigos.
ChangeEnemies:
ld hl, flags
bit $03, (hl)
ret z
res $03, (hl)
ld b, $14
ld hl, enemiesConfig
ld a, (swEnemies)
ld c, a
Cargamos en HL la dirección de los flags, LD HL, flags, comprobamos si el bit de cambio de dirección está activo, BIT $03, (HL), y salimos si no lo está, RET Z.
Desactivamos el bit si está activo, RES $03, (HL), cargamos en B el número total de enemigos, LD B, $14, cargamos en HL la dirección de la configuración de los enemigos, LD HL, enemiesConfig, cargamos en A el valor de la etiqueta auxiliar que usamos para el cambio de dirección de los enemigos, LD A, (swEnemies), y preservamos el valor cargándolo en C, LD C, A.
changeEnemies_loop:
bit $07, (hl)
jr z, changeEnemies_endLoop
inc hl
ld a, (hl)
and $3f
or c
ld (hl), a
dec hl
ld a, c
add a, $40
ld c, a
Comprobamos si el enemigo está activo, BIT $07, (HL), y saltamos si no lo está.
La dirección del enemigo está en los bits seis y siete del segundo byte de la configuración, por lo que apuntamos HL a este segundo byte, INC HL, cagamos el valor en A, LD A, (HL), desechamos la dirección actual del enemigo, AND $3F, agregamos la nueva dirección, OR C, y la actualizamos en memoria, LD (HL), A.
Apuntamos HL de nuevo al primer byte de la configuración, DEC HL, cargamos la nueva dirección en A, LD A, C, sumamos uno a la nueva dirección ($40 = 0100 0000), ADD A, $40, y cargamos el valor en C, LD C, A.
changeEnemies_endLoop:
inc hl
inc hl
djnz changeEnemies_loop
Apuntamos HL al primer byte del siguiente enemigo, INC HL, INC HL, y repetimos hasta que B valga cero y hayamos recorrido los veinte enemigos, DJNZ chengeEnemies_loop.
changeEnemies_end:
ld a, c
ld (swEnemies), a
ret
Cargamos la nueva dirección en A, LD A, C, actualizamos el valor en memoria para la próxima vez que haya que cambiar la dirección, LD (swEnemies), A, y salimos, RET.
El aspecto final de la rutina es el siguiente:
; -----------------------------------------------------------------------------
; Cambia la dirección de los enemigos.
;
; Altera el valor de los registros AF, BC y HL.
; -----------------------------------------------------------------------------
ChangeEnemies:
ld hl, flags ; Carga la dirección de memoria de flags en HL
bit $03, (hl) ; Comprueba si el bit 3 (cambio dirección) está activo
ret z ; Si no es así, sale
res $03, (hl) ; Desactiva el bit 3 de flags
ld b, $14 ; Carga en B el número total de enemigos (20)
ld hl, enemiesConfig ; Cara en HL la dirección de la configuración
; de los enemigos
ld a, (swEnemies) ; Carga en A el auxiliar para cambiar la dirección
ld c, a ; Preserva el valor en C
changeEnemies_loop:
bit $07, (hl) ; Comprueba si el enemigo está activo
jr z, changeEnemies_endLoop ; Si no lo está, salta a final del bucle
inc hl ; Apunta HL al segundo byte de la configuración
ld a, (hl) ; Carga el valor en HL
and $3f ; Desecha la dirección
or c ; Agrega la nueva dirección
ld (hl), a ; Actualiza la dirección en memoria
dec hl ; Apunta HL al primer byte de la configuración
ld a, c ; Recupera la nueva dirección
add a, $40 ; Le suma uno a la dirección ($40 = 0100 0000)
ld c, a ; Preserva la nueva dirección en C
changeEnemies_endLoop:
inc hl ; Apunta HL al primer byte de la configuración
inc hl ; del siguiente enemigo
djnz changeEnemies_loop ; Hasta que B sea cero (20 enemigos)
changeEnemies_end:
ld a, c ; Recupera la nueva dirección
ld (swEnemies), a ; La actualiza en memoria
ret
A esta nueva rutina hay que llamarla desde el bucle principal del programa. Volvemos al archivo Main.asm, localizamos la etiqueta Main_loop, localizamos la línea CALL MoveShip, y justo debajo de ella y antes de CALL MoveEnemies, añadimos la siguiente línea:
call ChangeEnemies
Si queréis, podéis compilar, cargar en el emulador y ver que todo sigue funcionando igual, no hemos roto nada, pero tampoco se produce el cambio de dirección, debido a que en ningún momento estamos activando el bit tres de flags.
Abrimos el archivo Int.asm para implementar la activación de este bit cada cuatro segundos (en sistemas PAL), dando uso así a las interrupciones.
Lo primero que vamos a hacer es añadir una constante al inicio del archivo, justo por debajo de ORG $7E5C:
T1: EQU $c8
A T1 le asignamos un valor de $C8, doscientos en decimal, que resulta de multiplicar cincuenta, que son las interrupciones que tenemos por segundo en sistemas PAL, por cuatro segundos.
Al final del archivo vamos a añadir una etiqueta que vamos a usar para llevar el conteo de las interrupciones hasta que lleguen a doscientas, cuatro segundos.
countT1: db $00
Y ahora vamos a modificar la rutina de la interrupción. Localizamos la etiqueta Isr_end, y justo por encima de ella implementamos la parte con la que se controlan los cuatros segundos de los que venimos hablando.
Isr_T1:
ld a, (countT1)
inc a
ld (countT1), a
sub T1
jr nz, Isr_end
ld (countT1), a
set $03, (hl)
Cargamos el valor del contador en A, LD A, (countT1), incrementamos A, INC A, y actualizamos el valor del contador, LD (countT1), A.
Restamos el número de interrupciones que hay que alcanzar para activar el flag de cambio de dirección, SUB T1, y saltamos si no se ha alcanzado, JR NZ, Isr_end.
Si se han alcanzado los cuatros segundos (doscientas interrupciones), el resultado de la resta anterior es cero, y con ese valor actualizamos el contador, LD (countT1), A, y por último activamos el bit de cambio de dirección, SET $03, (HL).
Hay que cambiar otra línea. Tres líneas por encima de Isr_T1, encontramos la línea JR NZ, Isr_end. Esta línea hay que cambiarla dejándola de la siguiente manera:
jr nz, Isr_T1
Ahora sí, compilamos, cargamos en el emulador y comprobamos que, cada cuatro segundos, los enemigos cambian de dirección. De igual manera vemos que lo de irse a la derecha y disparar ya no da tan buen resultado, en el primer nivel quizá sí, pero en los siguientes, no.
Ahora obligamos al jugador a moverse por la pantalla, pero la velocidad a la que se mueven los enemigos no permite ver bien hacia dónde van, por lo que deberíamos bajar dicha velocidad. Volvemos a Int.asm, localizamos la parte en la que se activa el bit para mover los enemigos:
ld a, (countEnemy)
inc a
ld (countEnemy), a
sub $02
jr nz, Isr_T1
ld (countEnemy), a
set $02, (hl)
Y cambiamos SUB $02 por SUB $03.
Compilamos, cargamos en el emulador y comprobamos que ahora es más llevadero. Ajustad la velocidad como más os guste.
Todavía tenemos que trabajar más en el comportamiento de lo enemigos, pero ahora vamos a añadir un efecto de color.
Cambio de color
Como comenté anteriormente en este capítulo, vamos a añadir un efecto de color al movimiento de los enemigos, para ello hemos añadido la etiqueta enemiesColor en Var.asm.
El efecto va a consistir en cambiar el color de los enemigos desde uno (azul) a siete (blanco) cada vez que se muevan.
Vamos al archivo Print.asm y localizamos la etiqueta PrintEnemies, y justo debajo de ella vamos a añadir la implementación del efecto de color.
Lo primero es cambiar la primera línea de la rutia, LD A, $06.
ld a, (enemiesColor) ; Carga en A la tinta
De esta manera, el color en el que se pintan los enemigos lo tomamos de la nueva etiqueta.
La primera vez que pintamos los enemigos en un nivel, lo hacemos en amarillo. Vamos al archivo Game.asm, localizamos la etiqueta ChangeLevel, y al inicio de la misma añadimos estas dos líneas:
ld a, $06 ; Carga el color amarillo en A
ld (enemiesColor), a ; Actualiza el color en memoria
Si ahora compilamos y cargamos en el emulador, debemos seguir viendo como los enemigos se pintan en amarillo.
Seguimos en el archivo Game.asm y localizamos la etiqueta MoveEnemies, cuyo aspecto inicial es el siguiente:
MoveEnemies:
ld hl, flags ; Cargamos la dirección de memoria de flags en HL
bit $02, (hl) ; Comprueba si el bit 2 está activo
ret z ; Si no es así, sale
res $02, (hl) ; Desactiva el bit 2 de flags
ld d, $14 ; Carga en D el número total de enemigos (20)
ld hl, enemiesConfig ; Carga en HL la dirección de la configuración
; de los enemigos
moveEnemies_loop:
La implementación del cambio de color la vamos a realizar justo después de la línea RES $02, (HL).
ld a, (enemiesColor)
inc a
cp $08
jr c, moveEnemies_cont
ld a, $01
moveEnemies_cont:
ld (enemiesColor), a
Cargamos en A el color de los enemigos, LD A, (enemiesColor), lo incrementamos, INC A, comprobamos si hemos llegado a ocho, CP $08, y saltamos si no lo hemos hecho, JR C, moveEnemies_cont. Si no hemos saltado, hemos llegado a ocho y ponemos el color en azul, LD A, $01. Por último, actualizamos el color en memoria, LD (enemiesColor), A.
El aspecto del inicio de la rutina queda de la siguiente manera:
MoveEnemies:
ld hl, flags ; Carga la dirección de memoria de flags en HL
bit $02, (hl) ; Comprueba si el bit 2 está activo
ret z ; Si no es así, sale
res $02, (hl) ; Desactiva el bit 2 de flags
ld a, (enemiesColor) ; Carga el color de los enemigos en A
inc a ; Lo incrementa
cp $08 ; Comprueba si ha llegado a 8
jr c, moveEnemies_cont ; Si no ha llegado, salta
ld a, $01 ; Pone el color en azul
moveEnemies_cont:
ld (enemiesColor), a ; Actualiza el color en memoria
ld d, $14 ; Carga en D el número total de enemigos (20)
ld hl, enemiesConfig ; Carga en HL la dirección de la configuración
; de los enemigos
moveEnemies_loop:
Compilamos y cargamos en el emulador. Ahora sí podemos ver como los enemigos cambian de color.
Hasta ahora hemos ido cambiando el comportamiento de los enemigos, primero para que situarnos en una parte de la pantalla no nos permita superar los treinta niveles sin más, y segundo para dar un poco más de vistosidad.
Ha llegado el momento de abarcar el cambio más importante, vamos a dotar a nuestros enemigos de disparo.
Disparos enemigos
El disparo de los enemigos se va a activar cuando estén encima de nosotros y va a haber un máximo de cinco disparos activos a un mismo tiempo.
El primer paso es declarar las constantes que vamos a necesitar, abrimos Const.asm y localizamos la etiqueta WHITE_GRAPH que actualmente es una directiva EQU con el valor $9e, código de carácter que se corresponde con el gráfico que hemos definido para el carácter en blanco, lo cual es innecesario ya que el carácter en blanco ya está definido, es el carácter $20 (32). Dejamos la línea de la siguiente manera:
WHITE_GRAPH:EQU $20
Ahora localizamos la etiqueta ENEMY_TOP_R y justo debajo vamos a añadir las constantes para el número total de enemigos, el código de carácter para el disparo del enemigo y el número de disparos que puede haber activos a un mismo tiempo.
ENEMIES: EQU $14
ENEMY_GRA_F: EQU $9e
FIRES: EQU $05
Como podemos observar, el código de carácter $9e va a ser ahora el disparo de los enemigos.
Abrimos el archivo Var.asm, localizamos la etiqueta udgsCommon y nos vamos a la última línea:
db $00, $00, $00, $00, $00, $00, $00, $00 ; $9e Blanco
Modificamos esta línea y la dejamos como sigue:
db $00, $3c, $2c, $2c, $2c, $2c, $18, $00 ; $9e Disparo enemigo
Si hicisteis las prácticas del capítulo 0x01 Ensamblador ZX Spectrum Marciano – Gráficos, deberíais ser capaces que dibujar en papel o en las plantillas que se proporcionaron las representación del disparo enemigo, solo tenéis que hacer la conversión de hexadecimal a binario.
Justo encima de la etiqueta udgsCommon vamos a añadir etiquetas para la configuración de los disparos enemigos, y para llevar la cuenta de cuántos de ellos están activos.
; -----------------------------------------------------------------------------
; Configuración de los disparos de los enemigos
;
; 2 bytes por disparo.
; -----------------------------------------------------------------------------
; Byte 1 | Byte 2
; -----------------------------------------------------------------------------
; Bit 0-4: Posición Y | Bit 0-4: Posición X
; Bit 5: Libre | Bit 5: Libre
; Bit 6: Libre | Bit 6: Libre
; Bit 7: Activo 1/0 | Bit 7: Libre
; -----------------------------------------------------------------------------
enemiesFire:
defs FIRES * $02
enemiesFireCount:
db $00
Con DEFS reservamos tantos bytes como sea el resultado de multiplicar FIRES (número máximo de disparos enemigos a un mismo tiempo) por dos (dos bytes por disparo). Como podéis ver, la configuración de los disparos enemigos guardan cierta similitud con la configuración de los enemigos.
Abrimos el archivo Game.asm y vamos a empezar con la implementación necesaria para que nuestros enemigos disparen y nos pongan las cosas más difíciles.
Vamos a implementar una rutina que desactive todos los disparos enemigos, que llamaremos cada vez que iniciemos un nuevo nivel. Esta rutina la vamos a poner justo antes de la rutina Sleep.
ResetEnemiesFire:
ld hl, enemiesFire
ld de, enemiesFire + $01
ld bc, FIRES * 02
ld (hl), $00
ldir
ret
Apuntamos HL al primer byte de la configuración de los disparos enemigos, LD HL, enemiesFire, apuntamos DE al byte siguiente, LD DE, enemiesFire + $01, cargamos en BC el número de bytes que vamos a limpiar, LD BC, FIRES * 02, limpiamos el primer byte, LD (HL), $02, limpiamos el resto de bytes, LDIR, y salimos, RET.
En realidad también estamos limpiando (poniendo a cero) el contador de disparos activos, lo cual no nos va a suponer ningún problema. No obstante, si lo queréis evitar podéis añadir DEC BC antes de LDIR.
El aspecto de la rutina, una vez comentada, es el siguiente:
; -----------------------------------------------------------------------------
; Inicializa la configuración de los disparos enemigos
;
; Altera el valor de los registros BC, DE y HL.
; -----------------------------------------------------------------------------
ResetEnemiesFire:
ld hl, enemiesFire ; Apunta HL a la configuración de los disparos
ld de, enemiesFire + $01 ; Apunta DE al byte siguiente
ld bc, FIRES * 02 ; Carga en BC en número de bytes a limpiar
ld (hl), $00 ; Limpia el primer byte
ldir ; Limpia el resto
ret
La configuración de los disparos enemigos es una suerte de lista. Necesitamos una rutina para que actualice dicha lista, vea cuantos disparos están activos, los ponga al inicio de la lista y actualice en memoria el número de disparos activos. Esta rutina la vamos a implementar justo delante de ResetEnemiesFire.
Es poco probable que queramos tener más de cinco disparos enemigos a la vez, es posible que incluso tengamos menos. Basados en esto, vamos a hacer una rutina que no es la más optima, pero que funciona.
La rutina que vamos a implementar, por cada elemento de la lista va a recorrer la lista en su totalidad, por lo que vamos a usar dos bucles anidados. Para finalizar, vamos a implementar un tercer bucle para actualizar el número de disparos activos.
RefreshEnemiesFire:
ld b, FIRES
xor a
refreshEnemiesFire_loopExt:
push bc
ld ix, enemiesFire
ld b, FIRES
Cargamos en B el número máximo de disparos para que sea el contador del bucle exterior, LD B, FIRES, y ponemos A a cero, XOR A. Preservamos BC, PUSH BC, apuntamos IX a la configuración de los disparos de los enemigos, LD IX, enemiesFire, y volvemos a cargar en B el número máximo de disparos, en este caso como contador del bucle interior, LD B, FIRES.
En este caso, a la memoria vamos a acceder de manera indexada con el registro IX; en este capítulo de Porompompong se habla sobre los registros del Z80.
refreshEnemiesFire_loopInt:
bit $07, (ix+$00)
jr nz, refreshEnemiesFire_loopIntCont
ld c, (ix+$02)
ld (ix+$00), c
ld c, (ix+$03)
ld (ix+$01), c
ld (ix+$02), a
Evaluamos si el disparo está activo, BIT $07, (IX+$00), y saltamos si lo está, JR NZ, refreshEnemiesFire_loopIntCont.
Si no está activo cargamos el primer byte del siguiente disparo en C, LD C, (IX+$02), y luego lo cargamos en el primer byte del disparo apuntado por IX, LD (IX+$00), C. Cargamos el segundo byte del siguiente disparo en C, LD C, (IX+03), y luego lo cargamos en el segundo byte del disparo apuntado por IX, LD (IX+$01), C.
Por último, ponemos el primer byte del segundo disparo a cero, LD (IX+$02), A.
refreshEnemiesFire_loopIntCont:
inc ix
inc ix
djnz refreshEnemiesFire_loopInt
pop bc
djnz refreshEnemiesFire_loopExt
Apuntamos IX al primer byte del siguiente disparo, INC IX, INC IX, y seguimos en bucle hasta que B sea igual a cero, DJNZ refreshEnemiesFire_loopInt.
Recuperamos BC para obtener el contador del bucle exterior, PUSH BC, y seguimos en bucle hasta que B sea igual a cero, DJNZ refreshEnemiesFire_loopExt.
Llegados a este punto ya tenemos todos los disparos activos al inicio de la lista, solo queda contar cuantos disparos están activos. El conteo de los disparos activos lo vamos a llevar en A, recordad que ya lo pusimos a cero al inicio de la rutina, XOR A.
ld b, FIRES
ld hl, enemiesFire
refreshEnemiesFire_loopCount:
bit $07, (hl)
jr z, refreshEnemiesFire_end
inc a
refreshEnemiesFire_loopCountCont:
inc hl
inc hl
djnz refreshEnemiesFire_loopCount
refreshEnemiesFire_end:
ld (enemiesFireCount), a
ret
Cargamos en B el número máximo de disparos, LD B, FIRES, y apuntamos HL a la configuración de los disparos, LD HL, enemiesFire.
Evaluamos si el disparo está activo, BIT $07, (HL), y si no lo está salta, JR Z, refreshEnemiesFire_end.
Si está activo, incrementamos A para sumar un disparo activo, INC A, apuntamos HL al primer byte del siguiente disparo, INC HL, INC HL, y seguimos en bucle hasta que B sea igual a cero, DJNZ refreshEnemiesFire_loopCount.
Por último, actualizamos el número de disparos activos en memoria, LD (enemiesFireCount), A, y salimos, RET.
El aspecto final de la rutina es el siguiente:
; -----------------------------------------------------------------------------
; Actualiza la configuración de los disparos enemigos
;
; Altera el valor de los registros AD, BC, HL e IX.
; -----------------------------------------------------------------------------
RefreshEnemiesFire:
ld b, FIRES ; Carga en B el número máximo de disparos
xor a ; Pone A a 0
refreshEnemiesFire_loopExt:
push bc ; Preserva BC
ld ix, enemiesFire ; Apunta IX a la configuración de los disparos
ld b, FIRES ; Carga en B el número máximo de disparos
refreshEnemiesFire_loopInt:
bit $07, (ix+$00) ; Evalúa si el disparo está activo
jr nz, refreshEnemiesFire_loopIntCont ; Si lo está, salta
ld c, (ix+$02) ; Carga el byte 1 del siguiente disparo en C
ld (ix+$00), c ; Lo carga en el byte 1 del disparo actual
ld c, (ix+$03) ; Carga el byte 2 del siguiente disparo en C
ld (ix+$01), c ; Lo carga en el byte 2 del disparo actual
ld (ix+$02), a ; Pone a cero el byte 1 del siguiente disparo
refreshEnemiesFire_loopIntCont:
inc ix
inc ix ; Apunta IX al byte 1 del siguiente disparo
djnz refreshEnemiesFire_loopInt ; Bucle hasta que B = 0
pop bc ; Recupera BC para bucle exterior
djnz refreshEnemiesFire_loopExt ; Bucle hasta que B = 0
; Actualiza el número de disparos activos
ld b, FIRES ; Carga en B el número máximo de disparos
ld hl, enemiesFire ; Apunta HL a la configuración de los disparos
refreshEnemiesFire_loopCount:
bit $07, (hl) ; Evalúa si el disparo está activo
jr z, refreshEnemiesFire_end ; Si no lo está, salta
inc a ; Incrementa A = contador de disparos
refreshEnemiesFire_loopCountCont:
inc hl
inc hl ; Apunta HL al byte 1 del siguiente disparo
djnz refreshEnemiesFire_loopCount ; Bucle hasta que B = 0
refreshEnemiesFire_end:
ld (enemiesFireCount), a ; Actualiza el contador de disparos en memoria
ret
Ahora vamos a implementar la rutina que va a ir activando los disparos. Los disparos se van a activar cuando el enemigo esté en la misma coordenada horizontal que la nave, siempre que no estén ya activos todos los disparos.
Seguimos en Game.asm, localizamos la etiqueta MoveEnemies, y justo encima de ella implementamos la rutina que activa los disparos enemigos.
EnableEnemiesFire:
ld de, (shipPos)
ld hl, enemiesConfig
ld b, ENEMIES
Cargamos en DE la posición de la nave, LD DE, (shipPos), apuntamos HL a la configuración de los enemigos, LD HL, enemiesConfig, y cargamos en B el número máximo de enemigos, LD B, ENEMIES.
enableEnemiesFire_loop:
ld a, (enemiesFireCount)
cp FIRES
ret nc
push bc
ld a, (hl)
ld b, a
inc hl
and $80
jr z, enableEnemiesFire_loopCont
ld a, (hl)
and $1f
cp e
jr nz, enableEnemiesFire_loopCont
Cargamos en A el número de disparos activos, LD A, (enemiesFireCount), lo comparamos con el número máximo de disparos, CP FIRES, y salimos si lo hemos alcanzado, RET NC. Recordad que CP resta al registro A el valor especificado, sin cambiar el valor de A pero si el registro F. El flag de acarreo se activa mientras A sea menor que el valor indicado en CP, por lo tanto el flag de acarreo se desactiva cuando A sea mayor o igual al valor indicado en CP.
Preservamos el valor de BC, PUSH BC, cargamos en A el primer byte de la configuración del enemigo, LD A, (HL), lo cargamos en B, LD B, A, apuntamos HL al segundo byte de la configuración, INC HL, comprobamos si el enemigo está activo, AND $80, y saltamos si no lo está, JR Z, enableEnemiesFire_loopCont.
En el caso de que el enemigo esté activo, cargamos en A el segundo byte de la configuración, LD A, (HL), nos quedamos con la coordenada X, AND $1F, los comparamos con la coordenada X de la nave, CP E, y saltamos si no son la misa, JR NZ, enableEnemiesFire_loopCont.
Si no hemos saltado, tenemos que activar el disparo.
ld c, a
push hl
push bc
ld hl, enemiesFire
ld a, (enemiesFireCount)
add a, a
ld b, $00
ld c, a
add hl, bc
pop bc
ld (hl), b
inc hl
ld (hl), c
ld hl, enemiesFireCount
inc (hl)
pop hl
Cargamos en C la coordenada X del enemigo, LD C, A, con esto ya tenemos lista la configuración del disparo. Preservamos HL, PUSH HL, preservamos BC, PUSH BC, apuntamos HL a la configuración de los disparos enemigos, LD HL, enemiesFire, cargamos en A el número de disparos activos, LD A, (enemiesFireCount), lo multiplicamos por dos, ADD A, A, ponemos B a cero, LD B, $00, cargamos en C el número de bytes que hay que desplazarse, LD C, A, y se lo sumamos a HL para que apunte a la posición de la lista dónde vamos a poner la configuración del disparo, ADD HL, BC.
Recuperamos la configuración del disparo, POP BC, cargamos el primer byte de la configuración del disparo en memoria, LD (HL), B, apuntamos HL al segundo byte de la lista, INC HL, cargamos el segundo byte de la configuración del disparo en memoria, LD (HL), C, apuntamos HL al contador de disparos enemigos, LD HL, enemiesFireCount, lo incrementamos, INC (HL), y recuperamos HL para que apunte al segundo byte de la configuración del enemigo, POP HL.
enableEnemiesFire_loopCont:
pop bc
inc hl
djnz enableEnemiesFire_loop
ret
Recuperamos el valor de BC para recuperar el contador del bucle, POP BC, apuntamos HL al primer byte de la configuración del siguiente enemigo, INC HL, y seguimos en bucle hasta que recorramos todos los enemigos, B sea igual a cero, DJNZ enebleEnemiesFire_loop. Por último, salimos, RET.
El aspecto final de la rutina es el siguiente:
; -----------------------------------------------------------------------------
; Habilita el disparo del enemigo.
;
; Altera el valor de los registros AF, BC, DE y HL.
; -----------------------------------------------------------------------------
EnableEnemiesFire:
ld de, (shipPos) ; Carga en DE la posición de la nave
ld hl, enemiesConfig ; Apunta HL a la configuración de los enemigos
ld b, ENEMIES ; Carga en B el número total de enemigos
enableEnemiesFire_loop:
ld a, (enemiesFireCount) ; Carga en A el número de disparos activos
cp FIRES ; Lo compara con el máximo de disparos
ret nc ; Sale si se ha alcanzado
push bc ; Preserva el valor de BC
ld a, (hl) ; Carga en A el primer byte de la configuración
ld b, a ; Lo carga en B
inc hl ; Apunta HL al segundo byte de la configuración
and $80 ; Evalúa si el enemigo está activo
jr z, enableEnemiesFire_loopCont ; Si no lo está, salta
ld a, (hl) ; Carga en A el segundo byte de la configuración
and $1f ; Se queda con la coordenada X
cp e ; La compara con la de la nave
jr nz, enableEnemiesFire_loopCont ; Si no son la misma, salta
; Activa el disparo
; La configuración del disparo es la del enemigo
ld c, a ; Carga en C la coordenada X del enemigo
push hl ; Preserva HL
push bc ; Preserva BC, configuración del disparo
ld hl, enemiesFire ; Apunta HL a los disparos de los enemigos
ld a, (enemiesFireCount) ; Carga en A el contador de disparos
add a, a ; Lo multiplica por dos, dos bytes por disparo
ld b, $00
ld c, a ; Carga el desplazamiento en BC
add hl, bc ; Apunta HL al disparo que hay que activar
pop bc ; Recupera BC, configuración del disparo
ld (hl), b ; Carga en memoria el primer byte de la configuración
inc hl ; Apunta HL al segundo byte de la configuración
ld (hl), c ; Lo carga en memoria
ld hl, enemiesFireCount ; Apunta HL al contador de disparos enemigos
inc (hl) ; Lo incrementa en memoria
pop hl ; Recupera HL, segundo byte configuración enemigo
enableEnemiesFire_loopCont:
pop bc ; Recupera el valor de BC
inc hl ; Apunta HL al primer byte de configuración
; del enemigo siguiente
djnz enableEnemiesFire_loop ; Hasta que se recorra todos los enemigos, B = 0
ret
Como hemos dicho anteriormente, los disparos se habilitan cuando los enemigos están en la misma coordenada horizontal que la nave, por ese motivo la llamada a EnableEnemies la vamos a hacer desde la rutina MoveEnemies.
En MoveEnemies vamos a cambiar dos cosas: primero localizamos la etiqueta moveEnemies_cont, y la segunda línea, que ahora presenta este aspecto:
ld d, $14 ; Carga en D el número total de enemigos (20)
La dejamos así:
ld d, ENEMIES ; Carga en D el número total de enemigos
Antes hemos declarado la constante ENEMIES, y ahora hemos de sustituir la constante en todos aquellos lugares donde se carga el número de enemigos.
Ahora localizamos la etiqueta moveEnemies_end y tras la primera línea:
call PrintEnemies ; Pinta los enemigos
Añadimos la llamada a EnableEnemiesFire:
call EnableEnemiesFire ; Habilita los disparos de los enemigos
Ahora vamos a implementar una rutina para que mueva los disparos enemigos, igual que tenemos una rutina para mover los enemigos.
Seguimos en Game.asm, localizamos la etiqueta MoveFire y justo delante de ella implementamos la rutina que va a mover los disparos enemigos.
MoveEnemiesFire:
ld a, $03
call Ink
ld hl, flags
bit $04, (hl)
ret z
res $04, (hl)
Cargamos en A la tinta magenta, LD A, $03, cambiamos la tinta, CALL Ink, cargamos en HL los flags, LD HL, flags, y comprobamos si el bit cuatro está activo, BIT $04, (HL). Si el bit no está activo salimos, RET Z, si sí lo está lo desactivamos, RES $04, (HL).
Por lo que vemos, vamos a usar otro bit de nuestro byte de flags.
ld d, FIRES
ld hl, enemiesFire
moveEnemiesFire_loop:
ld b, (hl)
inc hl
ld c, (hl)
dec hl
Cargamos en D el número máximo de disparos, LD D, FIRES, apuntamos HL a la configuración de los disparos enemigos, LD HL, enemiesFire, cargamos el primer byte de la configuración en B, LD B, (HL), apuntamos HL al segundo byte, INC HL, lo cargamos en C, LD C, (HL), y volvemos a apuntar HL al primer byte, DEC HL.
bit $07, b
jr z, moveEnemiesFire_loopCont
res $07, b
call DeleteChar
ld a, ENEMY_TOP_B + $01
cp b
jr z, moveEnemiesFire_loopCont
dec b
call At
ld a, ENEMY_GRA_F
rst $10
set $07, b
Evaluamos si el disparo está activo, BIT $07, B, y saltamos si no lo está, JR Z, moveEnemiesFire_loopCont. Si el disparo está inactivo podríamos salir de la rutina, pero no lo hacemos para que la rutina siempre tarde lo mismo en ejecutarse, o al menos lo más parecido posible entre cada ejecución.
Si el disparo está activo, nos quedamos con la coordenada Y, RES $07, B, borramos el disparo de su posición actual, CALL DeleteChar, cargamos en A el tope vertical al que puede llegar el disparo por abajo, LD A, ENEMY_TOP_B + $01, lo comparamos con la coordenada Y, CP B, y saltamos si la hemos alcanzado, JR Z, moveEnemiesFire_loopCont.
Si no hemos alcanzado el tope, apuntamos la coordenada Y a la línea siguiente, DEC B, posicionamos el cursor, CALL At, cargamos en A el gráfico del disparo enemigo, LD A, ENEMY_GRA_F, lo pintamos, RST $10, y dejamos el disparo activado, SET $07, B.
moveEnemiesFire_loopCont:
ld (hl), b
inc hl
inc hl
dec d
jr nz, moveEnemiesFire_loop
jp RefreshEnemiesFire
Al llegar a este punto, hemos activado o desactivado el disparo y actualizado la coordenada Y según corresponda. Actualizamos el primer byte de la configuración del disparo en memoria, LD (HL), D, apuntamos HL al primer byte del disparo siguiente, INC HL, INC HL, decrementamos D que es donde tenemos el número de iteraciones del bucle, DEC D, y seguimos en bucle hasta que D valga cero, JR NZ, moveEnemiesFire_loop.
Finalmente, saltamos a refrescar la lista de disparos y salimos por allí, JP RefreshEnemiesFire.
El aspecto final de la rutina es el siguiente:
; -----------------------------------------------------------------------------
; Mueve el disparo del enemigo.
;
; Altera el valor de los registros AF, BC, DE y HL.
; -----------------------------------------------------------------------------
MoveEnemiesFire:
ld a, $03 ; Cara la tinta 3 en A
call Ink ; Cambia la tinta
ld hl, flags ; Apunta HL a los flags
bit $04, (hl) ; Comprueba si está activo el flag mover disparo enemigo
ret z ; Si no lo está, sale
res $04, (hl) ; Desactiva el flag mover disparo enemigo
ld d, FIRES ; Carga en D en número máximo de disparos
ld hl, enemiesFire ; Apunta HL a los disparos enemigos
moveEnemiesFire_loop:
ld b, (hl) ; Carga en B la coordenada Y del disparo
inc hl ; Apunta HL a la coordenada X
ld c, (hl) ; La carga en C
dec hl ; Apunta HL a la coordenada Y
bit $07, b ; Evalúa si el disparo está activo
jr z, moveEnemiesFire_loopCont ; Salta si no lo está
res $07, b ; Se queda con la coordenada Y
call DeleteChar ; Borra el disparo de su posición actual
ld a, ENEMY_TOP_B + $01 ; Carga en A el tope
cp b ; Lo compara con la coordenada Y
jr z, moveEnemiesFire_loopCont ; Si es la misma, salta
dec b ; Apunta B a la línea siguiente
call At ; Posiciona el cursor
ld a, ENEMY_GRA_F ; Carga en A el gráfico del disparo
rst $10 ; Lo pinta
set $07, b ; Deja el disparo activo
moveEnemiesFire_loopCont:
ld (hl), b ; Actualiza la coordenada Y del disparo
inc hl
inc hl ; Apunta HL al primer byte de la configuración
; del siguiente enemigo
dec d
jr nz, moveEnemiesFire_loop
jp RefreshEnemiesFire ; Actualiza los disparos enemigos y sale
Ya tenemos todo casi listo para poder ver los disparos enemigos en pantalla.
Al inicio de la rutina MoveEnemies, recuperamos el valor de la etiqueta flags y evaluamos si el bit cuatro está activo.
Vamos al archivo Main.asm, y en los comentarios de flags, vamos a añadir el siguiente:
; Bit 4 -> mover disparo enemigo 0 = No, 1 = Sí
Seguimos en Main.asm y vamos a aprovechar para incluir las llamadas a algunas de las rutinas que hemos implementado.
Localizamos la etiqueta Main_start, y justo delante de CALL ChangeLevel vamos a incluir una llamada a la inicialización de los disparos enemigos:
call ResetEnemiesFire
Localizamos la rutina Main_loop, y entre las líneas CALL MoveEnemies y CALL CheckCrashShip, añadimos la llamada a la rutina que mueve los disparos enemigos:
call MoveEnemiesFire
Aprovechamos también para comentar la línea CALL CheckCrashShip, para que no nos maten los enemigos y poder ver mejor como quedan los disparos.
Por último, localizamos la rutina Main_restart, y casi al final, justo antes de CALL Sleep, añadimos otra llamada a la inicialización de lo disparos enemigos:
call ResetEnemiesFire
Hemos acabado en Main.asm, pero todavía nos queda activar el bit cuatro de flags para que todo esto funcione.
Vamos al archivo Int.asm, y lo primero que tenemos que hacer es decidir a que velocidad se va a mover el disparo enemigo. Sin implementar nada nuevo tenemos dos velocidades a elegir: a la velocidad a la que se mueve la nave (en cada interrupción), o la velocidad a la que se mueven los enemigos (cada N interrupciones).
En mi caso he optado por la segunda opción. Localizamos la línea SET $02, (HL), y justo debajo añadimos:
set $04, (hl)
Si queréis que se mueva a la velocidad a la que se mueve la nave, esta línea la tenéis que poner justo debajo de SET $00, (HL).
Hemos implementado una buena cantidad de líneas, y ya es hora de que probemos y veamos los resultados; compilamos y cargamos en el emulador.
Si todo va bien, ya pueden verse los disparos enemigos.
Dije anteriormente que cinco disparos enemigos simultáneos quizá fuera excesivo. Para apreciar mejor esto, en Game.asm localizamos la rutina MoveEnemies y, casi al final, comentamos la línea CALL PrintEnemies para poder ver mejor los disparos.
Compilamos, cargamos en el emulador y vemos los resultados.
Si a esto le sumamos los enemigos, quizá sea demasiado. Descomentamos la línea CALL PrintEnemies, y en Main.asm, en la rutina MainLoop, localizamos la línea CALL CheckCrashShip y quitamos el comentario.
Compilamos, cargamos en el emulador y verificamos que los enemigos nos vuelven a matar. Ya solo queda hacer que los disparos enemigos también nos maten.
Antes de implementar las colisiones entre la nave y los disparos enemigos, recordad que declaramos una constante con el número total de enemigos, ENEMIES, pero todavía tenemos partes del código en las que no la estamos usando.
Vamos a Print.asm, localizamos la rutina PrintEnemies, y modificamos la línea LD D, $14 dejándola así:
ld d, ENEMIES
El resto de modificaciones las vamos a realizar en Game.asm.
Localizamos la rutina ChangeEnemies, localizamos la línea LD B, $14 y la modificamos dejándola así:
ld b, ENEMIES
Localizamos las rutina CheckCrashFire, borramos las líneas:
ld b, enemiesConfigEnd - enemiesConfigIni
sra b
Y las sustituimos por:
ld b, ENEMIES
Hacemos la misma modificación en la rutina CheckCrashShip.
Y ahora ya sí, implementamos las colisiones entre la nave y los disparos enemigos. Seguimos en la rutina CheckCrashShip, vamos al final y justo antes de RET, añadimos las nuevas colisiones.
checkCrashShipFire:
ld de, (shipPos)
ld a, (enemiesFireCount)
ld b, a
ld hl, enemiesFire
Cargamos en DE la posición de la nave, LD DE, (shipPos), cargamos en A el número de disparos enemigos activados, LD A, (enemiesFireCount), lo cargamos en B, LD B, A, y apuntamos HL a la configuración de los disparos, LD HL, enemiesFire.
checkCrashShipFire_loop:
ld a, (hl)
inc hl
res $07, a
cp d
jr nz, checkCrashShipFire_loopCont
ld a, (hl)
cp e
jr nz, checkCrashShipFire_loopCont
Cargamos el primer byte de la configuración del disparo en A, LD A, (HL), apuntamos HL al segundo byte, INC HL, nos quedamos con la coordenada Y, RES $07, A, la comparamos con la coordenada Y de la nave, CP D, y saltamos si no son la misma, JR NZ, checkCrashShipFire_loopCont.
Si la coordenada Y del disparo y la de la nave son la misma, cargamos en A la coordenada X del disparo, LD A, (HL), la comparamos con la coordenada X de la nave, CP E, y saltamos si no son la misma, JR NZ, checkCrashShipFire_loopCont.
dec hl
res $07, (hl)
ld a, (livesCounter)
dec a
daa
ld (livesCounter), a
call PrintInfoValue
call PrintExplosion
jp RefreshEnemiesFire
Si hay colisión entre el disparo y la nave, apuntamos HL al primer byte de la configuración del disparo, DEC HL, desactivamos el disparo, RES $07, (HL), cargamos en A el número de vidas, LD A, (livesCounter), quitamos una vida, DEC A, hacemos el ajuste decimal, DAA, y actualizamos en memoria, LD (livesCounter), A.
Por último, pintamos la información de la partida, CALL PrintInfoValue, pintamos la explosión, CALL PrintExploxion, y actualizamos la lista de disparos enemigos y salimos por allí, JP RefreshEnemiesFire.
checkCrashShipFire_loopCont:
inc hl
djnz checkCrashShipFire_loop
Si no ha habido colisión, apuntamos HL al primer byte de la configuración del siguiente disparo, INC HL, y seguimos en bucle hasta que hayamos recorrido todos los disparos y el valor de B sea cero, DJNZ checkCrashShipFire_loop.
El aspecto final de la detección de colisiones entre la nave y los enemigos (naves y disparos enemigos) es el siguiente:
; -----------------------------------------------------------------------------
; Evalúa las colisiones de los enemigos y los disparos con la nave.
;
; Altera el valor de lo registros AF, BC, DE y HL.
; -----------------------------------------------------------------------------
CheckCrashShip:
ld de, (shipPos) ; Carga en DE la posición de nave
ld hl, enemiesConfig ; Apunta HL a la configuración de los enemigos
ld b, ENEMIES ; Carga en B el número de enemigos
checkCrashShip_loop:
ld a, (hl) ; Carga en A la coordenada Y del enemigo
inc hl ; Apunta HL a la coordenada X del enemigo
bit $07, a ; Evalúa si el enemigo está activo
jr z, checkCrashShip_endLoop ; Si no lo está, salta
and $1f ; Se queda con la coordenada Y del enemigo
cp d ; Compara con la coordenada Y de la nave
jr nz, checkCrashShip_endLoop ; Si no son iguales, salta
ld a, (hl) ; Carga en A la coordenada X del enemigo
and $1f ; Se queda con la coordenada X de enemigo
cp e ; Compara con la coordenada X de la nave
jr nz, checkCrashShip_endLoop ; Si no son iguales, salta
dec hl ; Apunta HL a la coordenada Y del enemigo
res $07, (hl) ; Desactiva el enemigo
ld a, (enemiesCounter) ; Carga en A el número de enemigos
dec a ; Resta uno
daa ; Hace el ajuste decimal
ld (enemiesCounter), a ; Actualiza el valor en memoria
ld a, (livesCounter) ; Carga las vidas en A
dec a ; Quita una
daa ; Hace el ajuste decimal
ld (livesCounter), a ; Actualiza el valor en memoria
call PrintInfoValue ; Pinta la información de la partida
jp PrintExplosion ; Pinta la explosión y sale
checkCrashShip_endLoop:
inc hl ; Apunta HL a la coordenada Y del siguiente enemigo
djnz checkCrashShip_loop ; En bucle hasta que B = 0
checkCrashShipFire:
; Comprueba colisiones entre disparos enemigos y nave
ld de, (shipPos) ; Carga en DE la posición de la nave
ld a, (enemiesFireCount)
ld b, a ; Carga el B el número de disparos activos
ld hl, enemiesFire ; Apunta HL a la configuración de los disparos
checkCrashShipFire_loop:
ld a, (hl) ; Carga la coordenada Y del disparo en A
inc hl ; Apunta HL a la coordenada X
res $07, a ; Se queda con la coordenada Y
cp d ; Compara si es la misma que la de la nave
jr nz, checkCrashShipFire_loopCont ; Si no es la misma, salta
ld a, (hl) ; Carga la coordenada X del disparo en A
cp e ; Compara si es la misma que la de la nave
jr nz, checkCrashShipFire_loopCont ; Si no es la misma, salta
; Si llega aquí, la nave ha colisionado con el disparo
dec hl ; Apunta HL al primer byte del disparo
res $07, (hl) ; Desactiva el disparo
ld a, (livesCounter) ; Carga las vidas en A
dec a ; Quita una
daa ; Hace el ajuste decimal
ld (livesCounter), a ; Actualiza el valor en memoria
call PrintInfoValue ; Pinta la información de la partida
call PrintExplosion ; Pinta la explosión
jp RefreshEnemiesFire ; Actualiza los disparos enemigos y sale
checkCrashShipFire_loopCont:
inc hl ; Apunta HL al siguiente disparo
djnz checkCrashShipFire_loop ; Bucle hasta que B = 0
ret
Llegados a este punto, ya tenemos el disparo de las naves enemigas implementado. Compilamos, cargamos en el emulador y vemos los resultados.
Ajuste de la dificultad
Quizá ahora la dificultad haya subido demasiado, o quizá no. Sea como fuere, vamos a ver algunos pequeños cambios que podemos realizar para ajustar la dificultad.
Los cambios que os propongo es para que hagáis pruebas, pero no los dejéis de manera permanente pues más adelante vamos a añadir una opción en el menú para que el jugador pueda seleccionar entre distintos niveles de dificultad.
La primera manera de reducir la dificultad va a ser reduciendo la velocidad a la que se mueven los enemigos y sus disparos. Vamos al archivo Int.asm, localizamos la línea SUB $03 y sustituimos $03 por $04, $05, $06, etc. Recordad que cuanto mayor sea este número, menor es la velocidad a la que se mueven los enemigos. Haced pruebas y veréis que la velocidad se reduce.
Otra forma de reducir la dificultad es reducir el número de disparos simultáneos. Vamos al archivo Const.asm, localizamos la etiqueta FIRES y cambiamos su valor por $01, $02, etc. Compilamos y vemos que al haber menos disparos a un mismo tiempo, la dificultad también se reduce.
También podemos reducir la dificultad evitando que los enemigos colisionen con la nave, para lo cual localizamos la etiqueta moveEnemies_Y_down y dos lineas más abajo tenemos SUB ENEMY_TOP_B, modificamos esta línea y la dejamos como sigue:
sub ENEMY_TOP_B + $01
Como podéis observar, los enemigos ya no colisionan con la nave, lo que reduce la dificultad. Si la reduce demasiado para vuestro gusto, probad a aumentar el número de disparos enemigos simultáneos.
Si los enemigos no colisionan con la nave, nos podríamos ahorra gran parte de la rutina CheckCrashShip, pero como vamos a cambiar estos comportamientos en base a la selección del jugador, la dejamos como está.
Otra forma que se me ocurre de disminuir la dificultad es al estilo Plaga Galáctica, el primer juego que cargué en mi Amstrad CPC 464. En Plaga Galáctica se dispone de tres vidas para superar cada nivel.
Para comenzar cada nivel con cinco vidas, vamos a Main.asm, localizamos la etiqueta Main_restart, y antes de CALL FadeScreen añadimos las líneas siguientes:
ld hl, livesCounter
ld (hl), $05
Con estas líneas, contaremos con cinco vidas al inicio de cada nivel.
Ensamblador ZX Spectrum, conclusión
En este capítulo hemos cambiado el comportamiento de los enemigos y los hemos dotado de disparo. También hemos visto distintas formas de ajustar la dificultad.
En el próximo capítulo vamos a implementar el sonido y la selección de dificultad.
El código generado lo tenéis disponible en este enlace.
Enlaces de interés
- Notepad++
- Visual Studio Code
- Sublime Text
- ZEsarUX
- PASMO
- Git
- Curso de ensamblador Z80 de Compiler Software
- Referencia Z80
- Ensamblador Z80 en Telegram
- Tutorial completo en formato PDF y EPUB
- Proyecto en itch.io
- Archivo .dsk con los juegos de los tutoriales
- Personalización y depuración con ZEsarUX
Ensamblador para ZX Spectrum Batalla espacial por Juan Antonio Rubio García.
Esta obra está bajo licencia de Creative Commons Reconocimiento-NoComercial-CompartirIgual 4.0 Internacional License.
También puedes visitar el resto de tutoriales:
Y recuerda, si lo usas no te limites a copiarlo, intenta entenderlo y adaptarlo a tus necesidades.