Hallo Besucher, der Thread wurde 83k mal aufgerufen und enthält 219 Antworten

letzter Beitrag von syshack am

Project J - Wir schreiben ein C64 Spiel in mehreren Schritten

  • Wie angedroht möchte ich hier eine Art Tutorial für ein C-64-Spiel vorstellen. Ich arbeite nach einem immer noch wachsenden Plan und werde jede Woche einen neuen Step hinzufügen. Die Kommentare im Code sind englisch, aber nichts von der heftigen Sorte. Da ich kein Hexer bin, sind da bestimmt einige Code-Teile ungünstig, evtl. falsch oder einfach unperformant. Für konstruktive Kritik bin ich immer offen.


    Das Spiel wird ein Single-Screen Action-Spiel, also kein Scrolling oder heftige Interrupt-Tricks (nur ein kleiner). Der Source ist für ACME vorgesehen, ein Binary liegt aber auch bei.


    Aktueller Stand des Spiels am 26.02.2011 ist Step 30 in Arbeit und sieht so aus:




    Der aktuelle Plan sieht so aus, wird sich aber bestimmt hin und wieder ändern:

  • Auf zu Schritt 1. Hier passiert nichts Eindrucksvolles. Es wird der VIC für das neue Speicherlayout vorbereitet und ein GameLoop gestartet. Auf der Vorlage bauen wir dann weiter auf.


    Das Programm zeigt erstmal eigentlich nichts (bzw. Speichermüll). Das Zeichen in der linken oberen Ecke wird durchrotiert und der Rahmen flackert.


  • Wir setzen auf das existierende Programm auf und setzen unser selbst erstelltes Charset ein. Das Charset wird beim Start an die gewünschet Speicheradresse $F000 kopiert (und dazu das ROM kurzfristig abgeschaltet). Die Datei J.CHR enthält das Charset und wird im Sourcecode als Binary included. Um es zu testen, wird noch HELLO links oben in die Ecke geschrieben, ansonsten bleibt der Rest des Screens bei dem Speichermüll stehen.


    Anbei findet ihr zusätzlich ein selbst erstelltes Tool (extrem stark an CharPad angelegt), um das Charset zu bearbeiten. Einfach die Datei J.CHARSETPROJECT öffnen. Um das Charset zu exportieren, auf "Export Charset" klicken und J.CHR auswählen.


  • Sehr ähnlich dem Step 2 setzen wir die Basis für Sprites auf. Ich habe dafür ein weiteres selbstgebasteltes Tool ("inspiriert" von SpritePad) verwendet. Die Datei J.SPR enthält die Sprites und wird im Sourcecode mit !binary eingebunden. Das erfolgt analog dem Charset mit abgeschaltetem ROM. Die Sprites landen an der Zieladresse $D000. An der Stelle kann man im RAM eigentlich nur Sprites oder Charset sinnvoll verwenden, da darüber im Normalfall die VIC Register eingeblendet werden.


    Da ich faul bin, ist die Sprite-Kopier-Routine auf 4er-Blöcke ausgelegt. Dadurch erspare ich mir eine Abfrage auf 64 Bytes, da 4 Sprites zusammen 256 Bytes belegen (genau genommen hat ein Sprite nur 63 Byte, aber das eine nehmen wir mit).


    Anbei findet ihr zusätzlich ein selbst erstelltes Tool C64Spriter. Einfach die Datei J.SPRITEPROJECT öffnen. Um die Sprites zu exportieren, auf "Export Charset" klicken und J.SPR auswählen. Es wird die angegebene Anzahl an Sprites exportiert.


    Der aktuelle Step zeigt den bekannten Bildschirmmüll mit "HELLO" in der linken oberen Ecke an, aber auch ein edles handgeklöppeltes Sprite.


  • Jetzt geht es erstmal mit einigen grösseren Schritten weiter: Wir bewegen das Sprite mit dem Joystick.


    Da wir da ein richtiges Spiel draus machen wollen, soll das Sprite auch über den ganzen Bildschirm bewegbar sein. Mit anderen Worten, wir berücksichtigen direkt das 9. X-Bit. Da nicht geplant ist, über den Bildrand hinaus zu gehen, gibt es dafür keine Kontrollabfrage. D.h. das Sprite bewegt sich einfach weiter, und sobald die Koordinate wrappt (sprich unter 0 oder über 255 geht), springt das Sprite einfach wieder zurück.


    Dieser Schritt zeigt:
    -Joystick-Steuerung (Port II)
    -Erweitertes X-Bit


  • Jetzt kommt erstmal ein großer Sprung:


    Klarerweise soll das Spiel ja nicht in Grafikmüll ablaufen. Daher gibt es in diesem Schritt eine Level-Zusammenbau-Routine, die einen Bildschirm aus veschiedenen "Primitiven" zusammensetzt. Andernfalls müsste man komplette Bildschirme speichern, was definitiv zuviel Speicher frißt. Im ersten Anlauf gibt es zunächst nur vertikale und horizontale Linien-Elemente. Da um jeden Level grundsätzlich ein Rahmen benötigt wird, wird dieser als eigene Level-Daten abgelegt (LEVEL_BORDER_DATA).
    Um das Aufbauen zu beschleunigen, gibt es eine zusätzliche Tabelle mit den Y-Offsets der Bildschirmzeilen (SCREEN_LINE_OFFSET_TABLE_LO und SCREEN_LINE_OFFSET_TABLE_HI). Nach diversen Tips (danke Hoogo!) bin ich zu der Überzeugung gekommen, daß man mit dem C64 Mnemonics am besten so oft wie möglich mit Tabellen arbeitet.


    Die BuildScreen Subroutine löscht den Bildschirm, baut den Level aus den Daten auf und legt dann den Rahmen darüber. Die Level-Daten sind eine Sammlung von Primitiven, die der Reihe nach abgearbeitet werden. Solange, bis LD_END erreicht ist.


    Dieser Schritt zeigt:
    -Löschen des Bildschirms
    -Zusammenstellen eines Levels aus Primitiven


  • Und weiter geht's. Der Spieler soll natürlich nicht durch Wände gehen können. In diesem Schritt setzen wir Kollisionskontrolle ein.


    Um das Vorgehen zu vereinfachen speichern wir pro Sprite zusätzlich die Zeichenposition (x und y) als auch das Delta innerhalb eines Zeichens (0 bis 7). Wenn das Sprite nicht genau an einer Zeichengrenze liegt, ist die Bewegung in diese Richtung erlaubt. Wenn das Sprite genau an eine Zeichengrenze gerät, werden die nächstgelegenen Zeichen abgefragt.


    Der Einfachkeit halber sind alle Zeichen größergleich 128 blockierend, alle darunter frei.


    Der Kollisionscode geht von einer Spritegrösse von 8 Pixel breit und 16 Pixel hoch aus.


    Dieser Schritt zeigt:
    -Zeichenposition während der Bewegung mitziehen
    -Zeichenposition auf blockierende Zeichen prüfen
    -Sprite-Position aus Zeichenposition errechnen (um ein Sprite an einer bestimmten Stelle zu positionieren)


  • Langsam sieht es aus wie ein Spiel. Sehr langsam.


    Jetzt kommt Schwerkraft (fallen) und springen dazu. Der Spieler fällt, wenn unter ihm kein blockierendes Zeichen zu finden ist. Wird der Joystick nach oben gedrückt, springt der Spieler eine Parabel-ähnliche Kurve. Sowohl Fallgeschwindigkeit als auch Sprunghöhe werden über eine Tabelle gesetzt (FALL_SPEED_TABLE und PLAYER_JUMP_TABLE). Zusätzlich sind noch ein paar Logikabfragen enthalten, so daß man nur vom Boden stehend abspringen kann als auch nur einmal springen, bevor man den Boden wieder berührt hat.


    In diesem Schritt enthalten:
    -fallen
    -springen


  • Natürlich ist ein Spiel kein Spiel ohne Herausforderung (da kann man drüber diskutieren, aber nicht hier). Also müssen Gegner her. Da wir ja schon einen praktischen Level-Baukasten haben, warum den nicht auch für die Gegner benutzen?


    Wir erzeugen einen neuen Level Data Typ LD_OBJECT um Objekte (=Sprites) einzusetzen. Den benutzen wir gleich sowohl für den Spieler als auch für Gegner. Ein neuer Table SPRITE_ACTIVE wird hinzugefügt, um zu sehen, welche Slots aktiv sind und gleichzeitig welcher Objekttyp darin steckt.


    Eine zentrale Funktion dafür ist FindEmptySpriteSlot. Sie durchläuft die Slots und sucht einen freien Platz. Wird ein freier Slot gefunden, benutzen wir die schon vorliegende Funktion CalcSpritePosFromCharPos, um das Sprite auf die Zeichenposition zu positionieren.


  • Was nützen einem die schönsten Gegner, wenn sie nur dumm rumstehen? Daher bauen wir das mal ein. Wir fügen die Routine ObjectControl hinzu. Dazu gibt es einen Table mit Funktionspointern auf die Benimm-Routinen der Gegner (und des Spielers!).


    ObjectControl nimmt den Objekt-Typ als Index in diese Tabelle und springt den Pointer an. Für diesen Step gibt es zwei Gegner-Typen, dümmliches auf/ab sowie links/rechts. Für das Bewegen verwenden wir die bereits früher angelegten Funktionen des Spielers wie ObjectMoveLeft/ObjectMoveRight etc. wieder.


  • Ihr habt gemerkt, daß die Gegner dem Spieler noch nichts tun? Nun, in die Richtung arbeiten wir uns jetzt vor, jetzt kommen Kollisionsabfragen. Weil ich mir nicht über spätere Änderungen sicher bin, verlassen wir uns hier NICHT auf das Kollisionsregister des VIC sondern bauen unsere eigene Abfrage.
    Erinnern wir uns an die Objekt-Grössen aus Step 6, wir benutzen diese jetzt für die Kollisionsabfrage.


    Es wird eine neue Subroutine CheckCollisions hinzugefügt, welche ihrerseits IsEnemyCollidingWithPlayer benutzt. Wir prüfen keine Kollisionen zwischen Gegnern. Die Überprüfung ist nicht Pixel-genau (verhält sich eher wie 9 Pixel * 16 Pixel), aber gut genug für ein Spiel.


    Um eine Kollision anzuzeigen, färben wir den Rahmen weiß.


  • Die Kollision mit Gegner ist natürlich nur der erste Schritt. Sobald ein Spieler mit einem Gegner kollidiert, lassen wir ihn sterben, indem wir das Spieler-Objekt entfernen. Eine "Press Fire to Restart" Nachricht wird angezeigt, und mit einem beherzten Druck auf den Feuer-Knopf wird der Spieler wiederbelebt.


    Wir fügen die Funktion RemoveObject hinzu. Diese setzt einfach nur den Eintrag in SPRITE_ACTIVE auf leer und disabled das Sprite. Während wir auf den Feuerknopfdruck des Spielers warten geht der Rest des Spielablaufes weiter (ie. die Monster bewegen sich weiter).


  • Einer der komplexeren Schritte. Und damit auch einer, den ich nochmal gründlich überarbeiten müsste. Der Spieler kann jetzt Gegner besiegen.


    Die zentrale Funktion dafür ist FireShot. Da ich mir das Sprite für die Kugel sparen wollte, gibt es Insta-Shot. Es soll aber trotzdem undurchdringliche Wände geben. Das bedeutet, wir nehmen die aktuelle Spieler-Richtung und Position und arbeiten uns von dort nach links/rechts, bis wir einen Gegner oder eine Wand getroffen haben.
    Da wir keine direkte Kollisionsabfrage ermöglicht haben (ohne Sprite?) nehmen wir die Zeichenposition des Spielers, zählen eins drauf (oder weg) und vergleichen das mit der Position aller lebender Gegner. Das machen wir solange, bis die Kugel irgendwo einschlägt.


    Die Gegner sind mit 5 HP ausgestattet, der Spieler hat eine Schuss-Pause von 10 Frames. Daher dauert es eine Weile, bis ein Gegner platt geht. Am besten lässt sich das mit dem oberen Gegner testen. Wenn der eigene Spieler violett ist, ist die Schuß-Pause aktiv.


  • Und wieder ein etwas grösserer Schritt. Natürlich sollen die geplätteten Gegner auch Items hinterlassen. Items werden nicht als Sprites, sondern als 2x2 Zeichen grosser Block dargestellt. Eine Liste mit möglichen Items (ITEM_ACTIVE analog SPRITE_ACTIVE) und Item-Positionen wird angelegt. Die Liste speichert auch den originalen Hintergrund hinter den Items.


    Um ein Item zu erzeugen muß man nur neben einem der Gegner stehen (in seine Richtung sehen) und den Knopf gedrückt halten bis er stirbt.


    Zu beachten: Items aufsammeln geht noch nicht, das gibt es im nächsten Step.


  • Und weiter geht's im Programm. Das Aufnehmen der Items hat ein kleines Problem aufgezeigt.


    Sobald ein Item aufgenommen wird soll der Hintergrund dahinter wiederhergestellt werden (und die Item-Characters sollen kein Loch in den Level selbst schlagen). Nach langwierigem Überlegen (würfelwürfel) habe ich mich dazu entschieden, einen zweiten Bildschirmpuffer anzulegen, welcher den Level im Originalzustand hält.
    Jedesmal, wenn ein Item aus dem Array entfernt wird, werden die 4 Zeichen aus dem Puffer kopiert (die Farben haben wir noch extra gespeichert). Der Hintergrundpuffer wird auch für die Kollisionsabfrage verwendet (Items reißen keine Löcher mehr). Leider ließ sich nicht verhindern, daß nach dem Entfernen eines Items alle noch vorhandenen Items einmal neu dargestellt werden müssen (wegen überlappender Items).


  • Jetzt kommen ein paar Kleinigkeiten, die aber für den Gesamteindruck wichtig sind.


    Zuerst: Level-Fortschritt, d.h. nach dem Abräumen eines Levels geht es weiter zum nächsten.


    Relativ einfach. Wir halten einen Zähler der aktiven Gegner vor (NUMBER_ENEMIES_ALIVE) welcher zu Beginn eines Levels natürlich auf 0 steht. Für jedes Gegner-Objekt wird der Zähler hochgezählt. Für jeden vernichteten Gegner einen runtergezählt. Sobald der Zähler die 0 erreicht hat, springt eine Level-Ende-Verzögerung an (hier im Beispiel mit Rahmen-Flackern gekennzeichnet). Sobald die Verzögerung durch ist, wird der nächste Level angesprungen.


    Im Moment sind hier zwei einfache Level enthalten, sobald der letzte geschafft ist, geht es wieder von vorne los.


  • Na klar :)


    Anfangs habe ich einfach nur TextPad mit ACME verwendet, für das Charset und die Sprites die Tools die in Step 2 und 3 mit bei sind.


    Inzwischen habe ich auf mein C64Studio gewechselt, weil ich damit in Verbindung mit Vice durch meinen Code steppen kann, was das Debuggen ungemein erleichtert. Das geht zwar auch mit Vice direkt, aber dort muß man mühsam seine Labels einbringen (Sublabels exportiert ACME leider gar nicht) und sämtliche Konstanten sind natürlich futsch.
    Im C64 Studio sind Sprite und Charset-Editoren mit integriert, dafür gibt es auch einen eigenen Thread hier (C64 Studio - Entwicklungsumgebung - Beta).

  • Versuch mal den emu64. Der hat einen Debugger mit dem Zyklen weise schauen kann wo sich der Rasterstrahl gerade befindet.


    Der wird gerade auf Linux portiert und bekommt Internationalisierung. ;)