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:

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.

Dit levert 4 waarden op:

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:

Probeer het zelf

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

Probeer het zelf

Je kan natuurlijk de waarde $ff verlagen om hem sneller te laten lopen. Wil je hem langzamer hebben, voeg dan NOPs (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

Probeer het zelf

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:

tot en met

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

Probeer het zelf

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:

tot en met

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

Probeer het zelf

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

Probeer het zelf

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:

Probeer het zelf

Einde

Ik hoop dat je veel plezier hebt beleefd aan deze tutorial, en dat je er wat van hebt opgestoken.