Lekker Low Level 4: Pong

Hanneke Techniek

In de afgelopen drie delen van deze serie hebben we je geïntroduceerd tot de MOS Technology 6502 en assembly voor deze processor. Nu is het tijd om deze kennis in de praktijk te brengen, en gaan we een klein spelletje maken: Pong!

Pong

We gaan een simpele versie van Pong maken, met een speelveld waarbinnen de bal stuitert en aan de linkerkant een paddle die de speler op en neer kan laten bewegen. Zodra de bal het speelveld verlaat is de speler af wordt het spel ten einde.

Doel van deel 4

In dit deel komen we een eind in de richting van ons eindresultaat. We zorgen voor een speelveld en een paddle die we kunnen bewegen met toetsinvoer. Ook hebben we een routine om de bal te tekenen.

Display

Laten we eerst eens kijken hoe het display van de simulator werkt.

Er zijn 32 bij 32 pixels (in totaal dus 1024), en elke pixel heeft 1 geheugenadres (1 byte). Daarvoor zijn dus 4 pagina’s van 256 bytes geheugen nodig, deze vind je in de adresruimte $0200 - $05ff. De pixels staan als 1 lange rij bytes achter elkaar, er is dus geen organisatie met een x- en een y-lokatie. De pixel linksboven vind je dus op $0200, de pixel daarnaast op $0201 enzovoorts, tot en met $0219 voor de pixel rechtsboven. De volgende rij pixels gebruikt de adressen van $0220-$023f, tot en met de laatste rij op $05e0 - $05ff.

Om het adres van een pixel op (x, y) te vinden kan je dus het volgende sommetje maken: adres = $0200 + x + (y * $20), maar er is geen instructie om te vermenigvuldigen. Natuurlijk is wel wel ASL, maar alles bij elkaar zijn dat veel kostbare instructies. Hier maken we later in deze post (en vooral de volgende) andere creatieve oplossingen voor.

We hebben de beschikking over 16 kleuren:
Kleurenoverzicht
Het is een klein beetje zonde om per pixel een hele byte te reserveren als we maar een halve nodig hebben, maar het maakt het ook wel een stuk makkelijker. De simulator gebruikt alleen de laagste nibble om de kleur te bepalen, dus $01 is hetzelfde wit als $11, $21 enzovoorts.

Deze eigenschap zou je bijvoorbeeld kunnen gebruiken om bijvoorbeeld 16 verschillende vijanden uit elkaar te kunnen houden terwijl ze toch dezelfde kleur hebben op het scherm, door het id van de vijand in de hoge nibble te zetten.

Met dit loopje tekenen we de eerste 16 pixels met alle mogelijke kleuren:

LDX #$0f
loop:
TXA STA $0200,x
DEX BNE loop

 

Probeer het zelf

Opzet

Om het allemaal eenvoudig te houden, gebruiken we een global state voor de hele applicatie. We zullen een aantal geheugenadressen definen die dienst doen voor verschillende variabelen zoals de positie van de bal of onze paddle.

We maken gebruik van subroutines om het overzicht te houden, maar hoeven hier dus geen variabelen aan mee te geven of van terug te krijgen. We hoeven ons in de subroutines ook geen zorgen te maken of we niet het geheugen overschrijven op plekken waar dat niet zo handig is.

We beginnen met 2 subroutines:

  • init, die we eenmalig aanroepen om het spel op te zetten;
  • loop, de gameloop die we continue blijven aanroepen totdat het spel voorbij is.

Deze zullen we hierna verder in gaan vullen.

  JSR init
  JSR loop

init:
  RTS

loop:
  JMP loop

end:

Probeer het zelf

Initialisatie

We moeten een aantal dingen doen voordat we kunnen gaan spelen. Zo moeten we een aantal variabelen initaliseren en het speelveld tekenen.

Speelveld

We tekenen eerst de horizontale lijnen. Dit doen we meteen in een subroutine, die we straks naar ons uiteindelijke project kunnen overzetten:

define color_wall $07

  JSR drawBoundaries
  JMP end

drawBoundaries:
  LDA #color_wall

  LDX #$00
drawBoundaries_horizontal:
  STA $0240,x
  STA $05a0,x
  INX
  CPX #$20
  BNE drawBoundaries_horizontal

  RTS

end:

Probeer het zelf

Omdat in het geval van een horizontale lijn de pixels gewoon naast elkaar zijn, kunnen we hier een eenvoudige loop gebruiken. We willen voor de bovenste lijn de pixels $0240 - $025f vullen, dat kunnen we mooi doen met de absolute,x adresseringsmodus waarbij X telt van $00 - $20. Voor de onderste lijn geldt hetzelfde, maar dan vanaf adres $05a0.

De verticale lijn is wat ingewikkelder. We beginnen bij de pixel op adres $027f. Voor de pixel daaronder moeten we dus $20 bij dat adres optellen, en voor de pixel daaronder weer, tot we uitkomen bij $059f, de laatste pixel.

We moeten hier dus een 16 bit adres bijhouden, en dat is de groot voor onze 8 bit processor. Daarom definiëren we nu twee plaatsen in het geheugen om te gebruiken als een tijdelijke variabele.

define addr_temp_l $00
define addr_temp_h $01

define color_wall $07

  JSR drawBoundaries
  JMP end

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

end:

Probeer het zelf

In addr_temp_l en addr_temp_h slaan we eerst het adres op van de eerste pixel die we willen tekenen: $027f. Nu willen we op die plek een pixel tekenen, hiervoor moeten we dus een indirecte manier van adresseren gebruiken. Helaas hebben we hiervoor alleen Indexed Indirect (($nn,x)) en Indirect Indexed (($nn),y) tot onze beschikking, want Indirect (($nnnn)) is alleen maar beschikbaar voor JMP. We kunnen hier wel prima gebruik van maken, alleen moeten we opletten dat het gebruikte indexregister (hier Y) op 0 staat.

Na het tekenen van de pixel doen we een 16 bit optelling op addr_temp. Daarna moeten we kijken of het resultaat groter is dan $059f, het adres van de laatste pixel die we willen tekenen.
In A staat nog steeds de hoge byte van het adres, dat komt goed uit want daar moeten we als eerste mee vergelijken. Als die lager is dan $05 zitten we sowieso nog goed dus continueren we de loop. Als die gelijk is aan $05 moeten we ook de lage byte checken. Als deze kleiner is dan $a0 moeten we nog verder tekenen, anders keren we terug van de subroutine.

Paddle

Het tekenen van de paddle is de volgende uitdaging. Dit is ook een verticale lijn dus daarvoor kunnen we in principe dezelfde code gebruiken. Dat betekent wel een vrij intensief loopje iedere keer dat hij getekent moet worden, dus daar gebruiken we een andere leuke oplossing voor: een lookup table met pointers naar de juiste pixels.

Omdat we het scherm nooit wissen tekenen we boven- en onderaan de paddle altijd een zwarte pixel, zodat bij het verplaatsen geen “restjes” paddle achterblijven.

De bovenste positie van de paddle (inclusief de zwarte pixel) is $0240, de onderste $05a0. Dit levert een reeks van 28 adressen op. Deze kunnen we met DCB definiëren in een lookup tabel. Vervolgens kunnen we middels Indirect Indexed adressering de juiste pixel benaderen door de paddle y-positie (maal 2, want de adressen zijn 16 bits) op te tellen bij het startadres van de tabel.

define addr_paddle_pos $02

define color_bg $00

define color_wall $07
define color_paddle $05

define paddle_size $05

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

  LDA #$00
  STA addr_paddle_pos

  JSR drawPaddle
  JMP end

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

end:

Probeer het zelf

We beginnen drawPaddle door de y-positie van de paddle in A te laden en 1x naar links te shiften, zodat hij vermenigvuldigt wordt met 2. Vervolgens transferen we die waarde naar X met TAX (Transfer Accumulator to X). Vervolgens gebruiken we X in combinatie met paddle_lookup om de eerste zwarte pixel te tekenen.

Voor de loop laden we de kleur van de paddle in A en de grootte van de paddle in Y, welke we zullen gebruiken als loopteller. In de loop verhogen we X steeds 2 keer voor elke keer dat we Y verlagen, omdat we natuurlijk steeds 2 bytes moeten opschuiven in de lookuptable.

Om af te sluiten tekenen we nog een zwarte pixel onderaan de paddle, en we overschrijven $0240 en $05a0 nog even met de kleur van de muur – dat is gemakkelijker dan een constructie schrijven om $0240 en $05a0 nooit te overschrijven met zwart.

Bal

De bal is wellicht het simpelste element om te tekenen, want het is maar een enkele pixel. Dat maakt hem niet minder belangrijk: zonder bal geen pong!

Voor de bal houden we het geheugenadres bij van de pixel waar hij op staat (dit zijn dus weer 2 bytes). Dat is sneller dan een x- en een y-lokatie bijhouden die we dan weer moeten omrekenen naar een positie op het beeld. Daarnaast moeten we ook de oude positie van de bal bijhouden, want als hij verplaatst moeten we daaroverheen tekenen met een zwarte pixel, net als bij de paddle (of een grijze, om het pad te tonen – dat is sowieso fijn als je aan het debuggen bent!). Hiervoor reserveren we dus in totaal 4 geheugenadressen.

define addr_ball_l $03
define addr_ball_h $04
define addr_ball_old_l $05
define addr_ball_old_h $06

define color_ball $01
define color_ball_streak $0b

  LDA #$29
  STA addr_ball_old_l
  STA addr_ball_l

  LDA #$04
  STA addr_ball_old_h
  STA addr_ball_h

  JSR drawBall
  JMP end

drawBall:
  LDA #color_ball_streak
  LDY #$00
  STA (addr_ball_old_l),y
  LDA #color_ball
  STA (addr_ball_l),y

  RTS

end:

Probeer het zelf

De drawBall subroutine zal inmiddels niet veel geheimen meer voor je hebben: we tekenen een grijze pixel op de oude positie van de bal, en daarna een witte op de huidige positie. In de huidige situatie zie je dus alleen een witte pixel, iets links van het midden, op de startpositie.

Combineren

Als we nu alles combineren, hebben we de initialisatie-code compleet! En we hebben ook al onze functies om ons beeld bij te werken, dus nu hoeven we alleen nog maar het balletje in het rond te stuiteren en de paddle op en neer te bewegen.

Voor het resultaat kijk je hier.

Input

We kunnen de invoer aflezen uit adres $ff. Hier staat de ASCII-code van de laatst ingedrukte toets. Omdat de pijltjestoetsen geen ASCII-code hebben gebruiken we ‘W’ en ‘S’ (van WASD) als omhoog en omlaag.

De paddle heeft een range van 0 tot 26 (het scherm is 32 pixels hoog, en daar gaan in totaal 6 pixels af voor de boven- en onderborder). De paddle is 5 pixels hoog, dus de range van de paddle is 0 tot 21 ($15).

define addr_system_key $ff

define keycode_up $77 ; W
define keycode_dn $73 ; S

define paddle_size $05
define paddle_max_pos $15

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

Probeer het zelf

We beginnen handleKeys met het lezen van de input en deze vergelijken we met de keycodes waarin we geïnteresseerd zijn. We branchen in het geval dat de paddle moet bewegen, anders keren we meteen terug uit de subroutine.

Zodra we een toets herkend hebben schrijven we ook meteen $00 naar $ff, om te zorgen dat de paddle niet blijft bewegen nadat we de toets hebben losgelaten.

Als we omhoog willen, laden we de waarde van de huidige positie in A en trekken daar $01 vanaf. Als dit een negatieve waarde oplevert brancht BMI (Branch MInus) naar upTopReached waar we de positie weer op 0 zetten. Anders slaan we de nieuwe positie op.

Omlaag is ongeveer hetzelfde: alleen hier vergelijken we met paddle_max_pos. Als A >= paddle_max_pos dan is C geset en branchen we naar downBottomReached.

Voeg deze code ook toe aan project, voor dit resultaat.

Deel 5

We zijn al een heel stuk gekomen met ons spelletje. In het volgende deel van deze serie trekken we onze laatste truckendoos open, en laten we de bal door het scherm stuiteren – als je hem raakt, ten minste.

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of bel met Jolanda.