Blog 8 Min. Lesezeit

Von alten Spielen lernen: Prince of Persia & Doom 3

Diesen Artikel teilen
Learn from Old Games: Prince of Persia & Doom3

Heute gibt es für jede Programmiersprache zahlreiche ausgereifte Bibliotheken und Frameworks, und die Sprachen wurden um viele fortgeschrittene Funktionen erweitert. Doch wie sah es bei älteren Projekten aus, als die Sprachfunktionen noch längst nicht so weit entwickelt waren und nur wenige ausgereifte Bibliotheken zur Verfügung standen?

Werfen wir einen Blick auf einige ältere, gut umgesetzte Projekte und sehen wir uns an, wie sie realisiert wurden.

Prince of Persia

Prince of Persia ist ein Fantasy-Plattformspiel, das ursprünglich von Jordan Mechner entwickelt und 1989 für den Apple II veröffentlicht wurde. Es stellte einen großen Fortschritt bei der Qualität von Animationen in Videospielen dar. Am 17. April 2012 Jordan Mechner veröffentlichte den Quellcode von Prince of Persia.

Viele Spieler erinnern sich an dieses außergewöhnliche Spiel – vielleicht haben einige von Ihnen es damals monatelang gespielt.

pp0

Entwickler aus jener Zeit erinnern sich an PCs mit vielleicht 8-MHz-Prozessor, 1 Megabyte Arbeitsspeicher, einer 20-Megabyte-Festplatte und einem Diskettenlaufwerk. Ein Spiel wie Prince of Persia zu entwickeln, war unter diesen Bedingungen eine enorme Herausforderung.

Hinzu kam, dass Google damals nicht verfügbar war, um technische Probleme schnell zu lösen; manche Schwierigkeiten konnten Entwickler tagelang beschäftigen. Und als wäre das nicht genug, wurde das Spiel in 6502-Assembler entwickelt.

Trotz all dieser Einschränkungen ist der Quellcode sehr gut umgesetzt:

  • It’s  modularized using directories and files:

Modularität ist eine Softwaredesign-Technik, bei der Software stärker aus voneinander getrennten Bestandteilen aufgebaut wird. Modularer Code lässt sich leichter verwalten und warten. Prince of Persia wurde mithilfe von Verzeichnissen und Dateien modularisiert – diese Form der Modularität wird vom Betriebssystem bereitgestellt und lässt sich unabhängig von der Programmiersprache einsetzen.

Der Code ist auf viele Dateien verteilt. Hier einige davon:

pp6
  • The naming is easy to understand

Beim Durchsehen des Quellcodes findet man – anders als in vielen neueren Projekten – keine Variablennamen wie a, b oder x. Die Namen sind sinnvoll gewählt, sodass keine Kommentare nötig sind, um ihren Zweck zu erklären.

pp2
  •  Der Code ist in viele kleine Unterprogramme aufgeteilt

6502-Assembler ist sehr hardwarenah. Um den Code verständlicher und wartbarer zu machen, wurde deshalb das Prinzip „Teile und herrsche“ angewandt. Der Code besteht aus vielen kleinen Unterprogrammen, die sich dadurch leichter lesen und pflegen lassen. Hier ein Beispiel aus dem Quellcode:

pp3

Obwohl zahlreiche Einschränkungen die Arbeit der Entwickler 1989 erschwerten, ist der Code hervorragend umgesetzt. Warum sind dann 2014 – mit leistungsfähigen Rechnern, mächtigen Sprachen, Tausenden von Bibliotheken und Google – manche Projekte immer noch schlecht implementiert?

Sprachen und Frameworks sind lediglich Werkzeuge zum Erstellen von Anwendungen. Entscheidend ist der Entwickler. Selbst mit der besten Sprache und den besten Frameworks kann schlechter Code entstehen.

Viele Praktiken für sauberen Code sind unabhängig von der verwendeten Sprache. Gute Entwickler brauchen ein solides Urteilsvermögen und sollten ihren Code stets sauber und verständlich halten.

Doom 3

Doom 3 ist ein Videospiel, entwickelt von id Software und veröffentlicht von Activision. Das Spiel war für id Software mit mehr als 3,5 Millionen verkauften Exemplaren ein kommerzieller Erfolg.

maxresdefault

Am 23. November 2011 setzte id Software seine Tradition fort und veröffentlichte den Quellcode der vorherigen Engine. Viele Entwickler analysierten diesen Quellcode. Hier beispielsweise das Feedback von Fabien (Originalquelle):

Doom 3 BFG ist in C++ geschrieben, einer so umfangreichen Sprache, dass sich damit hervorragender, aber auch erschreckend schlechter Code erzeugen lässt. Glücklicherweise entschied sich id Software für eine C++-Teilmenge, die „C with Classes“ nahekommt und sich vergleichsweise leicht nachvollziehen lässt:
  • Keine Exceptions.
  • Keine Referenzen (stattdessen Zeiger).
  • Minimaler Einsatz von Templates.
  • Const überall.
  • Klassen.
  • Polymorphie.
  • Vererbung.

Viele C++-Experten empfehlen den Ansatz „C mit Klassen“ heute nicht mehr. Doom 3 wurde jedoch zwischen 2000 und 2004 entwickelt, was erklären könnte, warum moderne C++-Mechanismen nicht eingesetzt wurden.

Analysieren wir den Quellcode mit CppDepend und finden wir heraus, was ihn so besonders macht.

Doom 3 ist mithilfe einiger Projekte modularisiert. Hier die Liste der Projekte sowie einige Statistiken zu ihren Typen:

doom5

Der folgende Abhängigkeitsgraph zeigt die Beziehungen zwischen ihnen:

doom2

Doom 3 definiert viele globale Funktionen. Der Großteil der Verarbeitung ist jedoch in Klassen implementiert.

Das Datenmodell wird mithilfe von Structs definiert. Um einen konkreten Eindruck von deren Verwendung im Quellcode zu erhalten, stellt die obige Metrikansicht sie als blaue Rechtecke dar.

In der Metric View wird die Codebasis als Treemap dargestellt. Treemapping ist eine Methode zur Visualisierung baumartig strukturierter Daten mithilfe verschachtelter Rechtecke. Als Baumstruktur dient dabei die übliche Codehierarchie:

  • Ein Projekt enthält Namespaces.
  • Ein Namespace enthält Typen.
  • Ein Typ enthält Methoden und Felder.
doom13

Wie zu erkennen ist, werden zahlreiche Structs definiert; mehr als 40 % der DoomDLL-Typen sind beispielsweise Structs. Sie werden systematisch zur Definition des Datenmodells eingesetzt. Diese Praxis findet sich in vielen Projekten, hat bei Multithreading-Anwendungen jedoch einen großen Nachteil: Structs mit öffentlichen Feldern sind nicht unveränderlich.

Ein wichtiges Argument für unveränderliche Objekte ist, dass sie nebenläufige Programmierung erheblich vereinfachen. Warum ist korrekter Multithreading-Code so schwierig? Weil der Zugriff mehrerer Threads auf Ressourcen synchronisiert werden muss. Und warum ist diese Synchronisierung schwierig? Weil sich Race Conditions zwischen zahlreichen Lese- und Schreibzugriffen verschiedener Threads auf unterschiedliche Objekte nur schwer ausschließen lassen. Was aber, wenn es keine Schreibzugriffe mehr gibt, der Zustand der von Threads verwendeten Objekte sich also nicht verändert? Dann ist keine Synchronisierung mehr erforderlich.

Suchen wir nach Klassen mit mindestens einer Basisklasse:

doom6

Fast 40 % der Structs und Klassen besitzen eine Basisklasse. Einer der Vorteile von Vererbung in der OOP ist Polymorphie; die im Quellcode definierten virtuellen Methoden sind hier blau dargestellt:

doom7

Mehr als 30 % der Methoden sind virtuell. Nur wenige davon sind rein virtuell. Hier die Liste aller definierten abstrakten Klassen:

doom9

Es sind lediglich 52 abstrakte Klassen definiert; 35 davon sind reine Interfaces, das heißt, alle ihre virtuellen Methoden sind rein virtuell.

doom8

Suchen wir nach Methoden, die RTTI verwenden.

doom17

Nur sehr wenige Methoden verwenden RTTI.

Zusammengefasst werden nur grundlegende OOP-Konzepte eingesetzt: keine fortgeschrittenen Design Patterns, kein übermäßiger Einsatz von Interfaces und abstrakten Klassen, nur begrenzte RTTI-Nutzung und als Structs definierte Daten.

Bis hierhin unterscheidet diesen Code nichts Besonderes von vielen anderen Projekten, die „C mit Klassen“ verwenden – ein Ansatz, den viele C++-Entwickler kritisieren.

Einige interessante Entscheidungen der Entwickler helfen dabei, das Erfolgsrezept zu verstehen:

1. Eine gemeinsame Basisklasse stellt nützliche Dienste bereit.

Viele Klassen erben von idClass:

doom10

The idClass  provides the following services:

  1. Instanzerzeugung.
  2. Verwaltung von Typinformationen.
  3. Ereignisverwaltung.
doom11

2. String-Verarbeitung vereinfachen

Strings gehören in Projekten meist zu den am häufigsten verwendeten Typen. Viele Operationen arbeiten mit Strings, weshalb entsprechende Funktionen zu ihrer Verarbeitung benötigt werden.

Doom 3 definiert die Klasse idStr, die nahezu alle nützlichen Methoden zur String-Verarbeitung enthält. Eigene Hilfsmethoden, wie sie bei String-Klassen anderer Frameworks häufig nötig sind, werden dadurch überflüssig.

3. Der Quellcode ist stark vom GUI-Framework (MFC) entkoppelt

In vielen MFC-Projekten ist der Code stark an dessen Typen gekoppelt, sodass MFC-Typen überall im Quellcode auftauchen.

In Doom 3 ist der Code weitgehend von MFC entkoppelt. Nur die GUI-Klassen besitzen eine direkte Abhängigkeit davon, wie die folgende CQLinq-Abfrage zeigt:

doom3

Diese Entscheidung wirkt sich stark auf die Produktivität aus. Nur die GUI-Entwickler müssen sich mit dem MFC-Framework befassen; alle anderen verlieren keine Zeit damit.

4. Eine sehr gute Utility-Bibliothek (idlib)

In fast allen Projekten gehören Utility-Klassen zu den meistverwendeten Typen, wie das Ergebnis der folgenden Abfrage zeigt:

doom4

Wie zu sehen ist, werden Utility-Klassen besonders häufig verwendet. Ohne ein gutes Framework für solche Hilfsfunktionen verbringen C++-Entwickler einen großen Teil ihrer Entwicklungszeit damit, sich mit der technischen Schicht auseinanderzusetzen.

idlib stellt nützliche Klassen mit den erforderlichen Methoden für Strings, Container und Speicherverwaltung bereit. Das erleichtert die Arbeit und ermöglicht es den Entwicklern, sich stärker auf die Spiellogik zu konzentrieren.

5. Die Implementierung ist sehr leicht verständlich

Doom 3 implementiert einen fest programmierten Compiler. C++-Entwickler wissen, dass die Entwicklung von Parsern und Compilern keine einfache Aufgabe ist. Dennoch ist die Doom-3-Implementierung sehr leicht nachvollziehbar und der Code ausgesprochen sauber.

Hier der Abhängigkeitsgraph der vom Compiler verwendeten Klassen:

doom16

Und hier ein Codeausschnitt aus dem Quellcode des Compilers:

doom15

Wir haben bereits den Quellcode vieler Parser und Compiler untersucht. Doch dies ist das erste Mal, dass wir auf einen Compiler stoßen, dessen Quellcode so leicht verständlich ist – und das gilt für den gesamten Doom-3-Code. Beim Erkunden dieses Quellcodes kann man eigentlich nur sagen: Wow, er ist wirklich gelungen.

Auch wenn die Designentscheidungen von Doom 3 sehr grundlegend wirken, haben die Entwickler vieles bewusst so gestaltet, dass sich das Team stärker auf die Spiellogik konzentrieren kann und die Arbeit an der technischen Schicht vereinfacht wird. Das steigert die Produktivität erheblich.

Wer jedoch „C mit Klassen“ verwendet, muss genau wissen, was er tut. Dafür ist ein Expertenniveau wie bei den Doom-3-Entwicklern erforderlich. Einsteiger sollten nicht das Risiko eingehen, die Empfehlungen von Modern C++ zu ignorieren.

Diesen Artikel teilen