Lekker Low Level 5: Pong afmaken
In het vorige deel van deze serie hebben we een speelveld gemaakt en een paddle die omhoog en omlaag kan bewegen op basis van toetsenbordinvoer. Om het af te maken moeten we de bal laten bewegen en stuiteren tegen de wanden en de paddle.
Uiteindelijk wordt dit ons eindresultaat:
Beweging
Eerst beginnen we met de beweging van de bal. De bal kan 4 richtingen op:
- Linksboven
- Rechtsboven
- Linksonder
- Rechtsonder
Dit kunnen we opsplitsen in 2 componenten: links en rechts, en omhoog en omlaag. Dit slaan we op in 2 bits van een waarde op addr_ball_direction
.
- Bit 0 betekent omhoog (
1
) / omlaag (0
) - Bit 1 betekent links (
1
) / rechts (0
)
Dit levert 4 waarden op:
$00 - b00
– rechts, omlaag$01 - b01
– rechts, omhoog$02 - b10
– links, omlaag$03 - b11
– links, omhoog
Als we nu 1 keer naar rechts shiften met LSR
, dan bevat de carry bit 0, dus als die 1
is moeten we omhoog, als die 0
is omlaag. We kunnen dus simpelweg branchen met BCS
of BCC
. Hetzelfde kan met 2x shiften voor links/rechts. Een andere optie is AND
, en branchen op basis van Z
. Gecombineerd met het feit dat we geen binary literals kunnen gebruiken in deze assembler vind ik zelf de LSR
-methode iets prettiger lezen, maar het kost wel een instructie meer. Dat gezegd hebbende, er zijn hier nog wel meer optimalisatieslagen te maken… maar laten we de code leesbaar proberen te houden.
Als we de beweging tot de basis terugbrengen, houden we het volgende over.
define addr_temp_l $00
define addr_temp_h $01
define addr_paddle_pos $02
define addr_ball_l $03
define addr_ball_h $04
define addr_ball_old_l $05
define addr_ball_old_h $06
define addr_ball_direction $07
define addr_system_key $ff
define color_bg $00
define color_wall $07
define color_paddle $05
define color_ball $01
define color_ball_streak $0b
define keycode_up $77 ; W
define keycode_dn $73 ; S
define paddle_size $05
define paddle_max_pos $15
define paddle_lookup $c0
*=$00c0
dcb $40, $02, $60, $02
dcb $80, $02, $a0, $02, $c0, $02, $e0, $02
dcb $00, $03, $20, $03, $40, $03, $60, $03
dcb $80, $03, $a0, $03, $c0, $03, $e0, $03
dcb $00, $04, $20, $04, $40, $04, $60, $04
dcb $80, $04, $a0, $04, $c0, $04, $e0, $04
dcb $00, $05, $20, $05, $40, $05, $60, $05
dcb $80, $05, $a0, $05
*=$0600
; ====== start ======
JSR init
JSR loop
; ====== init ======
init:
LDA #$29
STA addr_ball_old_l
STA addr_ball_l
LDA #$04
STA addr_ball_old_h
STA addr_ball_h
LDA #$00
STA addr_ball_direction
JSR drawBall
RTS
; ====== drawBall ======
drawBall:
LDA #color_ball_streak
LDY #$00
STA (addr_ball_old_l),y
LDA #color_ball
STA (addr_ball_l),y
RTS
; ====== loop ======
loop:
JSR updateBall
JSR drawBall
JMP loop
; ====== updateBall ======
updateBall:
LDA addr_ball_l
STA addr_ball_old_l
LDA addr_ball_h
STA addr_ball_old_h
LDA addr_ball_direction
LSR A
BCS updateBall_up
; === down ===
LDA addr_ball_l
CLC
ADC #$20
STA addr_ball_l
LDA addr_ball_h
ADC #$00
STA addr_ball_h
JMP updateBall_lr
; === up ===
updateBall_up:
LDA addr_ball_l
SEC
SBC #$20
STA addr_ball_l
LDA addr_ball_h
SBC #$00
STA addr_ball_h
; === left/right
updateBall_lr:
LDA addr_ball_direction
LSR A
LSR A
BCS updateBall_left
; === left ===
LDA addr_ball_l
CLC
ADC #$01
STA addr_ball_l
LDA addr_ball_h
ADC #$00
STA addr_ball_h
JMP checkCollision
; === left ===
updateBall_left:
LDA addr_ball_l
SEC
SBC #$01
STA addr_ball_l
LDA addr_ball_h
SBC #$00
STA addr_ball_h
checkCollision:
RTS
end:
Het updaten van de bal begint met het opslaan van de huidige positie in de oude, zodat we die met drawBall
kunnen overschrijven.
We laden de huidige richting van de bal in A
, en shiften naar rechts. Als de carry gezet is, moeten we omhoog (met een branch), anders omlaag (zonder branch). Voor omlaag hebben we geen label nodig, maar dit zou je eventueel kunnen toevoegen als je dat leesbaarder vindt (voor de performance maakt het niet uit).
Als we omhoog of omlaag moeten, verplaatsen we de bal 32 ($20
) pixels: de volledige breedte van het dislay. Daardoor komt de bal op de volgende (or vorige) rij terecht. Dit is weer een 16 bit som, dus bewerken we de hoge en lage bit.
Vervolgens laden we de richting weer in A
, maar nu om te checken of we naar links of naar rechts moeten. Dit is ongeveer dezelfde logica, alleen hoeven we nu maar 1 pixel op te schuiven.
Als we daarmee klaar zijn, jumpen we naar checkCollision
die nu nog leeg is. De bal zal dan ook het beeld “verlaten” en (als hij omlaag gaat) onze code deels overschrijven. Afhankelijk van je code zal hij dan ook waarschijnlijk crashen met “unknown opcode”.
Delay
De bal beweegt nu erg snel (helemaal omdat het tekenen van de paddle wordt overgeslagen). Daarom kunnen we een eenvoudige delay-loop toevoegen:
delay:
LDX #$ff
delay_loop:
DEX
BNE delay_loop
RTS
Je kan natuurlijk de waarde $ff
verlagen om hem sneller te laten lopen. Wil je hem langzamer hebben, voeg dan NOP
s (No OPeration) toe voor de DEX
.
Collisions
Nu onze bal beweegt en over onze code heen walst, is het tijd om het te laten stuiteren. Omdat de bal elke loop maximaal 1 positie verschuift, hoeven we geen rekening te houden met situaties waarin er meer dan 1 positie verschoven wordt.
In moderne game-engines werk je doorgaans met verplaatsing over tijd (bijvoorbeeld: de bal beweegt 100 pixels per seconde). Vervolgens kijk je hoeveel tijd er verstreken is sinds de vorige frame (bijvoorbeeld 1/20 seconde) en gebruik je dat om de verplaatsing uit te rekenen (1/20 * 100 = 5 pixels). Hier was allemaal geen tijd voor op de beperkte machines waar we het nu over hebben, dus werden dit soort dingen per frame bepaald. Natuurlijk was het ook zo dat bijvoorbeeld elke Commodore 64 op dezelfde snelheid werkte (behalve de verschillen tussen PAL en NTSC-versies…), dus was het ook niet zo noodzakelijk: je weet van te voren precies hoe lang elke berekening zal duren bij iedereen.
Verticaal
We beginnen met een check of de boven- of onderrand geraakt worden. In dat geval moet de verticale richting veranderen.
checkCollision:
LDA addr_ball_h
CMP #$02
BEQ checkCollision_inUpperBlock
CMP #$05
BEQ checkCollision_inLowerBlock
JMP checkCollision_right
; === in upper block
checkCollision_inUpperBlock:
LDA addr_ball_l
CMP #$80 ; C = ball_l >= 80
BCC checkCollision_flipVertical
JMP checkCollision_right
; === in lower block ===
checkCollision_inLowerBlock:
LDA addr_ball_l
CMP #$80 ; C = ball_l >= 80
BCS checkCollision_flipVertical
JMP checkCollision_right
checkCollision_flipVertical:
LDA addr_ball_direction
EOR #$01 ; flip bit 0 (up/down)
STA addr_ball_direction
; === right side ===
checkCollision_right:
RTS
We kijken eerst of de bal in het bovenste blok van het scherm zit ($0200 - $02ff
), of het onderste blok ($0500 - $05ff
). Daartussenin zitten we sowieso altijd goed.
Als de bal in het bovenste blok zit kunnen we de lage byte vergelijken, om te zien of hij in de bovenste lijn ($0260 - $027f
) is aangelandt. We willen dus vergelijken of A < $80
. Is dat het geval, dan flippen we de richting zodat de bal in de volgende frame naar beneden gaat.
In het onderste blok gebeurt vrijwel hetzelfde. Hierbij is de laatste lijn $0580 - $059f
, dus willen we vergelijken of A >= $80
.
Het wisselen van richting doen we eenvoudig met EOR
(Exclusive OR).
Horizontaal – rechts
Nu we verticaal kunnen stuiteren, moeten het horizontaal natuurlijk ook! Anders verdwijnt de bal steeds aan de zijkanten uit beeld om aan de andere kant weer terug te komen, dat is een beetje vreemd.
Aan de linkerkant hebben we de paddle, dus die bewaren we voor het laatst.
De pixels aan de rechterkant hebben als adres:
$021f
$023f
$025f
$027f
tot en met
$05ff
We hoeven dus alleen maar de lage byte te checken. Dat maakt het alweer een stukje gemakkelijker. We willen, in verband met de border aan de rechterkant natuurlijk nog 1 pixel naar links checken. Laten we zien wat $1e
, $3e
… $fe
met elkaar gemeen hebben.
Het valt op dat bij alle opties de lage nibble $e
is (binair 1110
), en de hoge nibble is altijd oneven. Dat betekent dat de lage bit hiervan altijd 1
is. De drie hoogste bits kunnen alle mogelijkheden hebben, dus daar zijn we niet in geïnteresseerd, maar we weten precies wat de lage 5 bits moeten zijn: 11110
!
Omdat we niet geïnteresseerd zijn in de hoogste 3 bits, doen we eerst een AND
met 0001 1111
($1f
). Dan zijn alleen de laagste 5 bits over en die kunnen we vergelijken met 0001 1110
($1e
) om te zien of de bal de rechterkant heeft geraakt.
Het was dus vooral even goed nadenken, maar de resulterende code is vrij eenvoudig:
checkCollision_right:
LDA addr_ball_l
AND #$1f
CMP #$1e
BEQ checkCollision_flipHorizontal
JMP checkCollision_end
checkCollision_flipHorizontal:
LDA addr_ball_direction
EOR #$02 ; flip bit 2 (left/right)
STA addr_ball_direction
checkCollision_end:
RTS
Horizontaal – links
Om te beginnen negeren we de paddle. Het checken of we de linkerkant raken is verrassend gelijk aan de rechterkant.
De pixels aan de linkerkant hebben de volgende adressen:
$0200
$0220
$0240
$0260
tot en met
$05e0
Ook hier stuitert de bal 1 pixel vanaf de kant, dus op $01
, $21
… $e1
. Alweer zijn we alleen geïnteresseerd in de laagste 5 bits, maar nu willen we dat ze gelijk zijn aan 00001
.
checkCollision_left:
LDA addr_ball_l
AND #$1f
CMP #$01
BEQ checkCollision_flipHorizontal
JMP checkCollision_end
Dit is natuurlijk tof, maar de bal stuitert en stuitert en we kunnen het spel niet verliezen.
Voordat we hier verder mee kunnen, zetten we deze code in het volledige spel, zodat we de borders weer zien, en we een paddle hebben om te bewegen. De complete versie (tot nu toe) vind je hier.
We hebben nu nog maar 1 klusje: zorgen dat we links alleen stuiteren als de paddle daar staat – en anders het spel beëindigen.
We hebben natuurlijk de positie van de paddle (in addr_paddle_pos
), maar niet die van de bal. We kunnen dit allemaal omrekenen natuurlijk, maar dat is weer een van die dingen die dan merkbaar kostbaar worden. Gelukkig is er een makkelijkere manier: we kunnen in het displaygeheugen kijken of de pixel links naast de bal de kleur heeft van de paddle (dit werkt natuurlijk alleen als de kleur van de paddle uniek is, maar dat is hij gelukkig).
Om dit te doen nemen we het adres van de bal en trekken hier 1 vanaf om de pixel links ervan te krijgen. Omdat we dit alleen maar doen als de bal exact 1 pixel van de linkerkant is hoeven we dit alleen maar te doen bij de lage byte (als je wil optimaliseren: we kunnen ook AND
-en met 1111 1110
($fe
) wat weer een instructie en een paar cycles scheelt).
checkCollision_left:
LDA addr_ball_l
AND #$1f
CMP #$01
BNE checkCollision_end
; Check paddle
LDA addr_ball_l
SEC
SBC #$01
STA addr_temp_l
LDA addr_ball_h
STA addr_temp_h
LDY #$00
LDA (addr_temp_l),y
CMP #color_paddle
BEQ checkCollision_flipHorizontal
JSR drawBall
JMP end
Deze code zal inmiddels geen geheimen meer voor je hebben! We trekken 1 af van de lage byte van de bal-positie, en slaan deze op in addr_temp_l
. De waarde van ball_h
kopiëren we direct naar de tijdelijke variable. Vervolgens gebruiken we weer de indirecte adressering om de kleur van die pixel in A
te laden. Als die gelijk is aan color_paddle
kunnen we stuiteren, anders tekenen we de bal nog even (de drawBall
in de loop raken we niet meer), en beëindigen we het spel.
Gefeliciteerd, je hebt nu Pong geprogrammeerd in 6502 assembly! Hier nog even de volledige versie:
define addr_temp_l $00
define addr_temp_h $01
define addr_paddle_pos $02
define addr_ball_l $03
define addr_ball_h $04
define addr_ball_old_l $05
define addr_ball_old_h $06
define addr_ball_direction $07
define addr_system_key $ff
define color_bg $00
define color_wall $07
define color_paddle $05
define color_ball $01
define color_ball_streak $0b
define keycode_up $77 ; W
define keycode_dn $73 ; S
define paddle_size $05
define paddle_max_pos $15
define paddle_lookup $c0
*=$00c0
dcb $40, $02, $60, $02
dcb $80, $02, $a0, $02, $c0, $02, $e0, $02
dcb $00, $03, $20, $03, $40, $03, $60, $03
dcb $80, $03, $a0, $03, $c0, $03, $e0, $03
dcb $00, $04, $20, $04, $40, $04, $60, $04
dcb $80, $04, $a0, $04, $c0, $04, $e0, $04
dcb $00, $05, $20, $05, $40, $05, $60, $05
dcb $80, $05, $a0, $05
*=$0600
; ====== start ======
JSR init
JSR loop
; ====== init ======
init:
LDA #$29
STA addr_ball_old_l
STA addr_ball_l
LDA #$04
STA addr_ball_old_h
STA addr_ball_h
LDA #$00
STA addr_paddle_pos
LDA #$00
STA addr_ball_direction
JSR drawBoundaries
JSR drawPaddle
JSR drawBall
RTS
; ====== drawBoundaries ======
drawBoundaries:
; === horizontal ===
LDA #color_wall
LDX #$00
drawBoundaries_horizontal:
STA $0240,x
STA $05a0,x
INX
CPX #$20
BNE drawBoundaries_horizontal
; === vertical ===
LDA #$7f
STA addr_temp_l
LDA #$02
STA addr_temp_h
LDY #$00
drawBoundaries_right:
LDA #color_wall
STA (addr_temp_l),y
LDA addr_temp_l
CLC
ADC #$20
STA addr_temp_l
LDA addr_temp_h
ADC #$00
STA addr_temp_h
; Check of adres > $059f
CMP #$05
BNE drawBoundaries_right
; A == $05
LDA addr_temp_l
CMP #$a0
BCC drawBoundaries_right
; A >= $a0
RTS
; ====== drawPaddle ======
drawPaddle:
LDA addr_paddle_pos
ASL A
TAX
LDA #color_bg
STA (paddle_lookup,x)
LDA #color_paddle
LDY #paddle_size
INX
INX
drawPaddle_loop:
STA (paddle_lookup,x)
INX
INX
DEY
BNE drawPaddle_loop
LDA #color_bg
STA (paddle_lookup,x)
LDA #color_wall
STA $0240
STA $05a0
RTS
; ====== drawBall ======
drawBall:
LDA #color_ball_streak
LDY #$00
STA (addr_ball_old_l),y
LDA #color_ball
STA (addr_ball_l),y
RTS
; ====== loop ======
loop:
JSR handleKeys
JSR updateBall
JSR drawPaddle
JSR drawBall
JSR delay
JMP loop
; ====== handleKeys ======
handleKeys:
LDA addr_system_key
CMP #keycode_up
BEQ handleKeys_up
CMP #keycode_dn
BEQ handleKeys_down
RTS
handleKeys_up:
LDA #$00
STA addr_system_key
LDA addr_paddle_pos
SEC
SBC #$01
BMI handleKeys_upTopReached
STA addr_paddle_pos
RTS
handleKeys_upTopReached:
LDA #$00
STA addr_paddle_pos
RTS
handleKeys_down:
LDA #$00
STA addr_system_key
LDA addr_paddle_pos
CLC
ADC #$01
CMP #paddle_max_pos; C = A >= paddle_max_pos
BCS handleKeys_downBottomReached
STA addr_paddle_pos
RTS
handleKeys_downBottomReached:
LDA #paddle_max_pos
STA addr_paddle_pos
RTS
; ====== updateBall ======
updateBall:
LDA addr_ball_l
STA addr_ball_old_l
LDA addr_ball_h
STA addr_ball_old_h
LDA addr_ball_direction
LSR A
BCS updateBall_up
; === down ===
LDA addr_ball_l
CLC
ADC #$20
STA addr_ball_l
LDA addr_ball_h
ADC #$00
STA addr_ball_h
JMP updateBall_lr
; === up ===
updateBall_up:
LDA addr_ball_l
SEC
SBC #$20
STA addr_ball_l
LDA addr_ball_h
SBC #$00
STA addr_ball_h
; === left/right
updateBall_lr:
LDA addr_ball_direction
LSR A
LSR A
BCS updateBall_left
; === left ===
LDA addr_ball_l
CLC
ADC #$01
STA addr_ball_l
LDA addr_ball_h
ADC #$00
STA addr_ball_h
JMP checkCollision
; === left ===
updateBall_left:
LDA addr_ball_l
SEC
SBC #$01
STA addr_ball_l
LDA addr_ball_h
SBC #$00
STA addr_ball_h
; ====== checkCollision ======
checkCollision:
LDA addr_ball_h
CMP #$02
BEQ checkCollision_inUpperBlock
CMP #$05
BEQ checkCollision_inLowerBlock
JMP checkCollision_right
; === in upper block ===
checkCollision_inUpperBlock:
LDA addr_ball_l
CMP #$80 ; C = ball_l >= 80
BCC checkCollision_flipVertical
JMP checkCollision_right
; === in lower block ===
checkCollision_inLowerBlock:
LDA addr_ball_l
CMP #$80 ; C = ball_l >= 80
BCS checkCollision_flipVertical
JMP checkCollision_right
checkCollision_flipVertical:
LDA addr_ball_direction
EOR #$01 ; flip bit 0 (up/down)
STA addr_ball_direction
; === right side ===
checkCollision_right:
LDA addr_ball_l
AND #$1f
CMP #$1e
BEQ checkCollision_flipHorizontal
; === left side ===
checkCollision_left:
LDA addr_ball_l
AND #$1f
CMP #$01
BNE checkCollision_end
; Check paddle
LDA addr_ball_l
SEC
SBC #$01
STA addr_temp_l
LDA addr_ball_h
STA addr_temp_h
LDY #$00
LDA (addr_temp_l),y
CMP #color_paddle
BEQ checkCollision_flipHorizontal
JSR drawBall
JMP end
checkCollision_flipHorizontal:
LDA addr_ball_direction
EOR #$02 ; flip bit 2 (left/right)
STA addr_ball_direction
checkCollision_end:
RTS
delay:
LDX #$ff
delay_loop:
DEX
BNE delay_loop
RTS
end:
Einde
Ik hoop dat je veel plezier hebt beleefd aan deze tutorial, en dat je er wat van hebt opgestoken.