iJS Magazin   2.2025 - Pre-Release

Preis: Gratis für entwickler.de-Leser

Erhältlich ab:  März 2025

Umfang:  48

Autoren / Autorinnen: 
Nils Hartmann ,  
Veikko Krypczyk ,  
Sebastian Springer ,  
Veikko Krypczyk ,  
Thomas Czogalik ,  
Manfred Steyer ,  
Veikko Krypczyk ,  
Sebastian Springer ,  
Manfred Steyer ,  
Veikko Krypczyk

Mit dem Open-Source-Framework Phaser gelingt ein einfacher Einstieg in die Entwicklung von 2D-Browserspielen. Im Beispielprojekt erstellen wir ein simples Platformer-Spiel.

Der Browser bietet einen hervorragenden Einstieg in die Spieleentwicklung. Er liefert sämtliche Grundlagen, die für ein Spiel erforderlich sind. Das reicht von der Darstellung mit HTML-Elementen, 2D-Canvas oder auch von 3D-Inhalten mit WebGL und WebGPU. Für die Umsetzung der Logik nutzen Sie JavaScript oder noch besser TypeScript. Auf dieser Basis können sie anfangen, nahezu jedes beliebige Spiel umzusetzen, sei es etwas Einfaches wie Tic Tac Toe oder etwas Aufwendigeres wie ein Platformer-Spiel oder gar ein 3D-Shooter.

Fangen Sie an, alles selbst zu implementieren, stoßen Sie sehr schnell auf grundlegende Probleme wie den Aufbau von Szenen, das Laden von Assets, die Steuerung oder die Integration einer Physik-Engine. Damit Sie sich einiges an Zeit und Aufwand sparen können, sollten Sie den Einsatz einer Game Engine in Betracht ziehen; es gibt sie wie Sand am Meer. Die Spanne reicht von leichtgewichtigen Open-Source-Implementierungen wie Phaser oder Pixi bis hin zu den großen kommerziellen Engines wie Unity oder Unreal. Wobei Letztere ihren Web-Renderer mit der Version 4 eingestellt hat. In diesem Artikel werfen wir einen Blick auf Phaser, eine weit verbreitete und seit Jahren etablierte Engine, die kontinuierlich weiterentwickelt wird.

Phaser ist nach eigenen Angaben ein HTML5-Game-Framework, das sich auf 2D-Renderings konzentriert. Für echte 3D-Spiele ist Phaser also (noch) die falsche Wahl. Das Team arbeitet an einer 3D-Unterstützung, die im Lauf des Jahres in das Framework integriert werden soll. Die Schwerpunkte von Phaser sind:

  • Performance: Im Kern ist Phaser ein sehr leichtgewichtiges Framework auf einer modernen Basis. Es unterstützt verschiedene Renderer, nutzt aber standardmäßig den GPU-beschleunigten WebGL Renderer, der für flüssige Animationen im Frontend sorgt. Die Auslieferung der fertigen Applikation hängt natürlich vom Umfang Ihres Spiels ab. Phaser versucht jedoch, die Build-Artefakte so kompakt wie möglich zu halten, damit die initiale Ladezeit möglichst gering bleibt.

  • Developer Experience: Phaser ist darauf ausgerichtet, einen schnellen und einfachen Einstieg in die Entwicklung von 2D-Spielen im Browser zu ermöglichen. Das Framework bietet eine umfangreiche Dokumentation sowie einsteigerfreundliche Tutorials. Das Phaser API hat sich über Jahre bewährt und ist konsistent und gut verständlich aufgebaut. Sobald Sie die grundlegenden Konzepte von Scenes und Game-Objekt verinnerlicht haben, steht Ihrem ersten Spiel nichts mehr im Weg.

  • Open Source: Phaser unterliegt der MIT-Lizenz. Das bedeutet, dass Sie das Framework sowohl in freie als auch in kommerzielle Spiele integrieren können. Sie dürfen den Code modifizieren und anpassen, falls es erforderlich ist. Sie müssen lediglich den Urhebervermerk zusammen mit dem Lizenztext mitliefern. Wobei moderne Build-Systeme diese Anforderung für Sie erledigen.

  • Integrierbarkeit: Sie können Phaser auf vielerlei Arten verwenden, der einfachste Weg führt über die Installation als npm-Paket oder über ein CDN. Alternativ können Sie auch eine Applikation auf Basis von Vite und TypeScript oder die Integration in ein SPA-Framework wie React, Angular oder Vue nutzen.

  • Distribution: Phaser setzt auf Webstandards; die Verteilung eines Spiels ist also ein Leichtes. Sie benötigen lediglich einen Server, der die Quelldateien des Spiels ausliefert, und einen Browser, auf dem Sie spielen können. Zusätzlich können Sie Ihr Spiel aber auch über kleine Umwege im App Store von Apple, im Google Play Store oder auf Steam vertreiben.

Bibliotheken und Frameworks im JavaScript-Ökosystem sind mittlerweile sehr gut darin, ihre Vorteile zu bewerben, doch kann Phaser das, was es verspricht, auch halten? Einer der Schwerpunkte des Frameworks ist es, sehr schnell von der Idee zum ersten Prototyp eines Spiels zu kommen. Genau diesen Anwendungsfall sehen wir uns jetzt an.

Dazu entwickeln wir ein einfaches Platformer-Spiel, also ein 2D-Browser-Spiel, bei dem Sie eine Spielfigur durch ein vordefiniertes Level steuern. Das Level soll sich über mehr als die Bildschirmbreite erstrecken. Bewegt sich die Spielfigur, soll ihr die Kamera folgen. Um das Ganze visuell noch etwas interessanter zu gestalten, soll ein Parallax-Effekt eingefügt werden, bei dem sich die verschiedenen Ebenen in unterschiedlicher Geschwindigkeit bewegen. Zusätzlich zur Spielfigur soll es mindestens einen Gegner im Level geben. Ihn kann die Spielfigur ausschalten, indem sie auf ihn springt. Berührt die Spielfigur den Gegner, wird ihr ein Gesundheitspunkt abgezogen. Neben dem Gegner soll es Gegenstände geben, die die Spielfigur auf dem Weg zum Ziel einsammeln kann, wodurch sie Punkte erhält.

Set-up des Spiels

Phaser bietet eine Vielzahl von möglichen Set-up-Szenarien. Sie reichen von der Einbindung des Frameworks über ein CDN, was die einfachste Möglichkeit darstellt, über ein vorgefertigtes Set-up mit einem Bundler wie Vite bis zur Integration in ein Framework wie etwa Vue. Für unser Spiel nutzen wir Vite. Das ermöglicht eine komfortable Entwicklung mit einem vorkonfigurierten Dev-Server sowie die Verwendung von TypeScript.

Für den Start in die Entwicklung stellt Ihnen Phaser ein Kommandozeilenwerkzeug zur Verfügung, das Sie mit den verschiedenen JavaScript-Paketmanagern nutzen können. Mit npm starten Sie das Werkzeug mit dem Kommando npm create @phaserjs/game@latest. Anschließend startet ein interaktiver Prozess, in dessen Verlauf Sie eine Reihe von Fragen beantworten müssen. Zuerst wird nach dem Projektnamen gefragt, der auch den Verzeichnisnamen vorgibt. Anschließend wählen Sie das zu verwendende Template aus. Für unser Beispiel nutzen wir die Option Web Bundler. Im nächsten Schritt wählen Sie aus, welcher Bundler verwendet werden soll. Die Antwort ist für unser Spiel Vite. Danach entscheiden Sie, ob Phaser nur eine Szene oder ein vollständiges Spiel erzeugen soll. Hier können Sie die Option Complete wählen. Schließlich aktivieren Sie die TypeScript-Unterstützung und können sich dann noch entscheiden, ob Sie den Phaser-Entwicklern mitteilen möchten, welches Template Sie ausgewählt haben. Stimmen Sie hier nicht zu, bricht der Set-up-Prozess ab. Bestätigen Sie die Übermittlung, bereitet das Kommandozeilenwerkzeug die Struktur Ihrer Applikation so weit vor, dass Sie direkt starten können.

Nachdem der Prozess erfolgreich beendet wurde, wechseln Sie in das neue Verzeichnis, installieren alle Abhängigkeiten mit dem Kommando npm install und starten den Dev-Server mit npm run dev. Ihr Spiel wird dann über http://localhost:8080 ausgeliefert.

Die Spielkonfiguration

Phaser arbeitet mit einer klassenbasierten Objektorientierung. Das bedeutet, dass die Objekte in einem Spiel durch Instanzen von Klassen repräsentiert werden. Die erste und grundlegende Klasse eines Spiels ist die Game-Klasse. Sie steht für das gesamte Spiel und erhält ein Konfigurationsobjekt bei der Instanziierung, über das Sie die grundlegenden Informationen zum Spiel übergeben können. Hier definieren Sie beispielsweise den Phaser Renderer. Standardmäßig nutzt Phaser WebGL, alternativ können Sie den Canvas Renderer auswählen oder Phaser die Wahl mit dem Auto-Renderer überlassen. In diesem Fall versucht das Framework zunächst das hardwarebeschleunigte Rendering über die GPU des Systems mit WebGL. Ist das nicht verfügbar, nutzt Phaser das Canvas API des Browsers, das von allen auf dem Markt existierenden Browsern (außer dem Internet Explorer 8) unterstützt wird.

Weitere grundlegende Einstellungen betreffen die Darstellung des Spiels wie beispielsweise die Anzeigegröße, das Containerelement, in das das Spiel gerendert wird, oder die Hintergrundfarbe.

Der Scale-Manager kümmert sich darum, dass das Spiel passend dargestellt wird. Dazu können Sie mit der scale-Eigenschaft festlegen, wie der Manager arbeiten soll. Mit dem mode-Wert Phaser.Scale.FIT nimmt das Spiel die verfügbare Fläche ein, wobei das Seitenverhältnis bewahrt wird. Die autoCenter-Eigenschaft mit dem Wert Phaser.Scale.CENTER_BOTH sorgt dafür, dass die Anzeige sowohl vertikal als auch horizontal zentriert dargestellt wird.

Eine weitere nützliche Konfigurationsdirektive ist die physics-Eigenschaft, mit der Sie die Physik-Engine von Phaser konfigurieren können. Generell haben Sie die Wahl zwischen drei verschiedenen Engines, die direkt von Phaser unterstützt werden:

  • Arcade: Die Arcade Engine ist die einfachste Lösung. Sie unterstützt nur grundlegende Formen und ist auch sonst in ihrem Funktionsumfang eingeschränkt. Allerdings ist ihre Handhabung sehr einfach und intuitiv, sodass sie die ideale Wahl für Einsteiger ist.

  • Matter.js: Diese Physik-Engine ermöglicht die Simulation komplexer physikalischer Interaktionen und unterstützt eine Vielzahl von Formen und Kollisionen. Sie ist komplexer als Arcade, bietet dafür jedoch deutlich mehr Funktionalität.

  • Phaser Box2D: Noch einen Schritt weiter geht die Box2D Engine. Sie ist eine etablierte Lösung, die mittlerweile seit über 18 Jahren kontinuierlich weiterentwickelt wird. Die Engine unterstützt komplexe Formen noch besser als Matter und ist auch in puncto Realismus und Effizienz besser.

Für unser schlichtes Platformer-Spiel reicht die Arcade Engine völlig aus, da sie Schwerkraft und Kollisionserkennung für einfache Formen wie Rechtecke unterstützt. Sobald Sie komplexere Formen benötigen, sollten Sie sich die Alternativen ansehen. Wobei schon schräge Ebenen für Arcade ein Problem darstellen – Sie können das zwar noch mit einem Plug-in lösen, mit steigenden Anforderungen an die Physik Ihres Spiels kommen Sie mit Arcade aber schnell an die Grenzen des Realisierbaren.

Phaser erlaubt Ihnen, über das Konfigurationsobjekt des Spiels weitere Aspekte zu beeinflussen. Legen Sie die Parameter nicht explizit fest, nutzt Phaser sinnvolle Standardwerte, sodass Sie auch sehr leichtgewichtig starten können. Andererseits haben Sie beispielsweise die Möglichkeit festzulegen, mit wie vielen Frames pro Sekunde Ihr Spiel laufen soll.

Hierfür definieren Sie ein fps-Objekt in der Konfiguration und legen mit dem target-Wert fest, wie viele fps Sie anstreben. Mit min geben Sie an, wie viele fps Sie mindestens erwarten. Die TimeStep-Klasse nutzt diese Werte, um zu überprüfen, ob es ein Performanceproblem gibt und um Einfluss auf das interne Timing von Phaser zu nehmen. Außerdem können Sie festlegen, ob Phaser das Timing des Game Loops mit der requestAnimationFrame-Funktion des Browsers steuert oder ob setTimeout zum Einsatz kommen soll. requestAnimationFrame ist in der Regel die bessere Wahl, da die Funktion mit dem Renderzyklus des Browsers synchronisiert ist.

Eine der wichtigsten Eigenschaften des Konfigurationsobjekts trägt den Namen scene. Sie enthält eine oder mehrere Szenen, die das Spiel darstellen.

Szenen in Phaser

Szenen gehören zu den zentralen Elementen der Spielorganisation in Phaser. Eine Szene kann grundsätzlich alles sein, vom Startscreen des Spiels über ein einzelnes Level bis hin zur gesamten Spielwelt. Im einfachsten Fall definieren Sie eine Objektstruktur, die eine create-Methode aufweist, und setzen diese als Wert für die scene-Eigenschaft des Konfigurationsobjekts Ihres Spiels.

Diese Single Scene Games sind in ihren Möglichkeiten eingeschränkt und eignen sich nur für kleine Spiele mit einem geringen Funktionsumfang. Eine bessere Kontrolle über Ihr Spiel und die Organisation der verschiedenen Objekte bekommen Sie, wenn Sie mehrere Szenen verwenden. Alternativ zur Objektstruktur können Sie einen klassenbasierten Ansatz nutzen. Phaser stellt Ihnen zu diesem Zweck die Scene-Klasse zur Verfügung, von der Sie Ihre eigenen Szenenklassen ableiten können.

Die Szene bildet den Lebenszyklus des Spiels beziehungsweise eines Teils des Spiels ab und implementiert dafür einen Satz von Methoden:

  • Constructor: wird beim Erzeugen der Szene aufgerufen und dient zur Initialisierung. Hier können Sie einen Schlüssel für die Szene vergeben, indem Sie den Konstruktor der Elternklasse mit der gewünschten Zeichenkette aufrufen. Über den Schlüssel können Sie die Szene beim Scene-Manager von Phaser ansprechen.

  • init(data): Die init-Methode ist die erste Lifecycle-Methode einer Phaser-Szene. Sie können sie nutzen, um die Szene zu konfigurieren, und beispielsweise den Startzustand definieren. Die Daten, die diese Methode beim Aufruf erhält, können Sie beim Starten der Szene über die launch- beziehungsweise start-Methode übergeben. Die init-Methode kommt in Phaser eher selten zum Einsatz.

  • preload: Viel wichtiger ist die preload-Methode. Deren Aufgabe ist es, die Assets, die Sie in der Szene benötigen, vorab zu laden, sodass die Szene so schnell wie möglich dargestellt wird. Die Assets des Spiels legen Sie normalerweise im public/assets-Verzeichnis ab. Über das load-Objekt der Scene-Klasse können Sie mit der image- beziehungsweise der spritesheet-Methode die entsprechenden Assets laden lassen. Die image-Methode lädt ein Bild und akzeptiert dafür einen Schlüssel, über den Sie den Asset später ansprechen, und den Pfad zur Datei. Bei spritesheet kommt neben diesen Informationen die Höhe und Breite des Ausschnitts und dessen Position dazu.

  • create(data): Die create-Methode richtet eine Szene initial ein, nachdem alle Assets durch die preload-Methode geladen wurden. Die konkreten Aufgaben der create-Methode hängen stark von der Art des zu entwickelnden Spiels und der Szene ab. In einem Level eines Platformer-Spiels platzieren Sie beispielsweise die Plattformen, den Spieler, Gegner und Items, die eingesammelt werden können. Außerdem nutzen Sie die Physik-Engine, um die Kollisionserkennung zu konfigurieren.

  • update(time, delta): Bei dynamischen Spielen wie einem Platformer spielt der Game-Loop eine herausragende Rolle. In dieser Schleife wird der Zustand des Spiels regelmäßig aktualisiert und die Änderungen auf dem Bildschirm dargestellt. Die update-Methode der Szene bildet genau diese Aufgabe ab. Phaser unterstützt Sie bei der Implementierung und ermöglicht es Ihnen beispielsweise, Bewegungen in Pixel pro Sekunde anzugeben, ohne dass sie sich selbst darum kümmern müssen, wie viel Zeit seit dem letzten Render-Aufruf vergangen ist.

Mit diesen grundlegenden Informationen über Phaser können wir nun darangehen, ein Level für das Platformer-Spiel zu implementieren.

Das erste Level

Im ersten Schritt soll das Spiel eine Umgebung darstellen, in der Sie sich mit der Spielfigur über Plattformen bewegen können. Dazu laden Sie in der preload-Methode der Szene zunächst die benötigten Assets. In Listing 1 sehen Sie den zugehörigen Quellcode.

Listing 1: Preload der Assets einer Szene

import { Scene } from 'phaser';

export class MyScene extends Scene {
  preload() {
    const assets = [
      { key: 'bg', path: 'assets/bg.png' },
      { key: 'ground', path: 'assets/platform.png' },
    ];

    assets.forEach((asset) => this.load.image(asset.key, asset.path));

    this.load.spritesheet('dude', 'assets/dude.png', {
      frameWidth: 32,
      frameHeight: 48,
    });
  }
}

Das Hintergrundbild für die Szene können Sie danach über den Schlüssel bg und das Bild für die Plattformen über ground ansprechen. Beides sind einfache Bilder, die Phaser im Level rendert. Da die Spielfigur animiert werden soll, können Sie sie nicht über ein einfaches Bild laden, sondern Sie nutzen die spritesheet-Methode, bei der Sie die Breite und Höhe des Frames angeben. Das Bild besteht aus mehreren Sektionen, die Phaser bei der Animation austauscht, sodass der Eindruck einer flüssigen Bewegung entsteht.

Listing 2: Implementierung der create-Methode einer Szene

create() {
  this.add.image(0, 0, 'bg').setOrigin(0, 0);

  const platforms = this.physics.add.staticGroup(); // static bodies bewegen sich nicht

  platforms.create(400, 568, 'ground').setScale(2).refreshBody();

  platforms.create(600, 400, 'ground');
  platforms.create(50, 250, 'ground');
  platforms.create(750, 220, 'ground');

  this.player = this.physics.add.sprite(100, 450, 'dude');

  if (this.player) {
    this.player.setBounce(0.2);
    this.player.setCollideWorldBounds(true);
    this.physics.add.collider(this.player, platforms);
  }

  this.anims.create({
    key: 'left',
    frames: this.anims.generateFrameNumbers('dude', { start: 0, end: 3 }),
    frameRate: 10,
    repeat: -1,
  });

  this.anims.create({
    key: 'turn',
    frames: [{ key: 'dude', frame: 4 }],
    frameRate: 20,
  });

  this.anims.create({
    key: 'right',
    frames: this.anims.generateFrameNumbers('dude', { start: 5, end: 8 }),
    frameRate: 10,
    repeat: -1,
  });
}

Die meiste Arbeit geschieht in der create-Methode, deren Implementierung Sie in Listing 2 sehen können. create fügt zunächst das Hintergrundbild ein. Die Positionierung auf den Koordinaten 0,0 bewirkt, dass die Mitte des Bildes dort liegt. Der Aufruf der setOrigin-Methode sorgt dafür, dass die linke obere Ecke auf die angegebenen Koordinaten gesetzt wird. Nach dem Hintergrundbild fügt die add.staticGroup-Methode eine Gruppe statischer Objekte in die Szene ein. Die Arcade Engine kennt zwei verschiedene Arten von Objekten: dynamische und statische. Statische Objekte werden nicht von Schwerkraft oder Beschleunigung beeinflusst. Plattformen, auf denen sich die Spielfigur bewegt, sind ein typisches Beispiel für solche statische Objekte. Die Objektgruppe vereinfacht die Definition von Kollisionen, damit Sie das nicht für jede Plattform separat durchführen müssen.

Nach den Plattformen folgt das Hinzufügen der Spielfigur mit einem Aufruf der add.sprite-Methode. Die Spielfigur erscheint etwas oberhalb einer Plattform und landet durch die Schwerkraft auf der Plattform. Die setBounce-Methode sorgt dafür, dass es einen leichten Bounce-Effekt gibt, die Spielfigur leicht zurückprallt und es nicht so wirkt, als würde die Figur auf der Plattform kleben. Mit setCollideWorldBounds teilen Sie Phaser mit, dass es nicht möglich sein soll, mit der Spielfigur die Spielwelt zu verlassen. Das Hinzufügen des Colliders mit add.collider stellt sicher, dass die Spielfigur nicht durch die Plattformen fallen kann.

Der letzte Abschnitt der create-Methode ist dafür verantwortlich, die Animationen der Spielfigur aufzusetzen. Die anims.create-Methode definiert die Animationen, die Sie über einen Schlüssel ansprechen können. Die Frames der Animationen ergeben sich aus dem geladenen Sprite und dem Anfangs- und Endframe. Zusätzlich können Sie die Framerate angeben und ob die Animation wiederholt werden soll.

Die beiden Methoden preload und create führt Phaser einmalig beim Erstellen einer Szene aus. Zur Laufzeit der Szene übernimmt die update-Methode die Hauptaufgabe. Mit ihr können Sie die Interaktion mit dem Spiel steuern und sich um die Bewegung der Spielfigur kümmern. Listing 3 enthält den Code, der die Tastatursteuerung der Spielfigur ermöglicht.

Listing 3: Update-Methode der Szene

update() {
  const cursors = this.input.keyboard?.createCursorKeys();

  if (cursors!.left.isDown) {
    this.player.setVelocityX(-160);

    this.player.anims.play('left', true);
  } else if (cursors!.right.isDown) {
    this.player.setVelocityX(160);

    this.player.anims.play('right', true);
  } else {
    this.player.setVelocityX(0);

    this.player.anims.play('turn');
  }

  if (cursors!.up.isDown && this.player.body!.touching.down) {
    this.player.setVelocityY(-200);
  }
}

Die Spielfigur soll sich in drei Richtungen steuern lassen: nach links, nach rechts und nach oben. Die vierte Richtung, nach unten, erledigt die Schwerkraft der Physik-Engine von selbst. Mit der createCursorKeys-Methode erhalten Sie ein Objekt, über das Sie den Zustand einzelner Tasten der Tastatur überprüfen können. Betätigen Sie die linke Pfeiltaste, ist die cursors.left.isDown-Eigenschaft mit dem Wert true belegt. In diesem Fall lassen Sie die Figur mit der setVelocityX-Methode und einem negativen Wert nach links laufen und spielen die passende Animation ab. Ähnlich gehen Sie mit den anderen Richtungen um. Eine kleine Besonderheit ergibt sich, wenn Sie weder nach links noch nach rechts laufen. In diesem Fall setzen Sie die Geschwindigkeit auf 0 und drehen die Spielfigur in eine neutrale Position (Abb. 1).

springer_phaser_1

Abb. 1: Die Spielfigur lässt sich in verschiedene Richtungen steuern

Mit diesem Stand des Quellcodes könne Sie die Spielfigur auf dem Bildschirm herumlaufen und springen lassen und sich von Plattform zu Plattform bewegen. Sie haben damit also bereits eine ganz rudimentäre Version eines Spiels implementiert.

Die Spielwelt erweitern

Ein Spiel besteht jedoch in den seltensten Fällen aus nur einer bildschirmfüllenden Szene. Also vergrößern wir die Spielwelt im nächsten Schritt, sodass sie nicht mehr auf den Bildschirm passt. Damit die Spielfigur nicht einfach aus dem Bildschirm herausläuft, können Sie die Kamera so konfigurieren, dass sie der Spielfigur folgt.

Diese Kamerabewegung ermöglicht Ihnen noch einen weiteren interessanten Effekt. Sie können verschiedene Ebenen in der Szene implementieren und diese mit unterschiedlicher Geschwindigkeit bewegen. Dieser Effekt wird Parallax-Effekt genannt und erzeugt Tiefe und zusätzliche Bewegung, um das Spiel optisch noch etwas interessanter zu gestalten. Als Ebenen nutzen wir das Hintergrundbild, das sich am langsamsten bewegt, die Spielfigur, die die Geschwindigkeit vorgibt, und Wolken im oberen Drittel des Bildschirms, die sich schneller als die Spielfigur bewegen. In Listing 4 sehen Sie den Code, der diese Features hinzufügt.

Lisiting 4: Kamera-Einstellung und Parallax-Effekt

export class MyScene extends Scene {
  create() {
    this.cameras.main
      .setBounds(0, 0, worldWidth, worldHeight)
      .startFollow(this.player);
    this.add
      .tileSprite(0, 0, worldWidth * 1.5, worldHeight, 'clouds')
      .setOrigin(0, 0)
      .setScrollFactor(1.5);
    this.add
      .tileSprite(0, 0, worldWidth, worldHeight, 'bg')
      .setOrigin(0, 0)
      .setScrollFactor(0.5);

    // ...
  }
  // ...
}

Die Hauptkamera ist für die Darstellung der Szene verantwortlich. Sie können sie über die cameras.main-Eigenschaft der Szene ansprechen. Mit setBounds setzen Sie den Bereich der möglichen Kamerabewegung. Die Begrenzung sorgt dafür, dass die Kamera nicht nach oben oder unten ausbrechen kann und auch nicht zu weit nach links oder rechts über die Grenzen der Spielwelt hinausläuft. Mit Hilfe der startFollow-Methode können Sie ein Objekt – die Spielfigur in diesem Fall – übergeben, dem die Kamera folgen soll. Mit dieser Anpassung bewegt sich die gesamte Szene innerhalb des Kamerabildes mit der gleichen Geschwindigkeit.

Um den Parallax-Effekt zu erzielen, definieren Sie für den Hintergrund und die Wolken einen unterschiedlichen Scroll-Faktor, den Sie mit der setScrollFactor-Methode festlegen können. Ein Wert größer als 1 bedeutet dabei eine schnellere Bewegung als die Spielfigur; mit einem Wert kleiner als 1 bewegt sich das Element langsamer.

Interaktion zwischen Spielfigur und Spielwelt

Nun können Sie sich mit der Spielfigur frei in der Welt bewegen. Sie können von Plattform zu Plattform springen und von einem Ende der Szene zum anderen laufen. Dieser Funktionsumfang wird jedoch schon nach kurzer Zeit langweilig, sodass wir uns im nächsten Schritt darum kümmern sollten, etwas Abwechslung in das Spiel zu bringen. Im ersten Schritt soll das Spiel beendet werden, wenn die Spielfigur in eine Lücke zwischen zwei Plattformen fällt.

Die setCollideWorldBounds-Methode verhindert aktuell, dass die Spielfigur die Welt verlassen kann. An die Stelle dieser Methode tritt nun eine eigene Implementierung. Zur besseren Übersicht können Sie bei wachsendem Umfang eines Spiels die Szene auch in mehrere Klassen aufteilen. So kann es beispielsweise sinnvoll sein, alle Aspekte, die die Spielfigur betreffen, in eine Player-Klasse auszulagern. Die update-Methode der Player-Klasse rufen Sie in der update-Methode der Szene auf. In Listing 5 sehen Sie die Implementierung der Player-update-Methode.

Listing 5: Erweiterung der update-Methode der Player-Klasse

update() {
  if (this.cursors!.left.isDown) {
    this.setVelocityX(-this.speed);

    this.anims.play('left', true);
    if (this.x <= this.width / 2) {
      this.x = this.width / 2;
      this.body!.velocity.x = 0;
    }
  } else if (this.cursors!.right.isDown) {
    this.setVelocityX(this.speed);

    this.anims.play('right', true);
    if (this.x > this.worldWidth - this.width / 2) {
      this.x = this.worldWidth - this.width / 2;
      this.body!.velocity.x = 0;
    }
  } else {
    this.setVelocityX(0);

    this.anims.play('turn');
  }

  if (this.cursors!.up.isDown && this.body!.touching.down) {
    this.setVelocityY(-this.jumpPower);
  }
  if (this.y > this.worldHeight) {
    this.scene.endGame();
  }
}

Diese Methode enthält die bisherige Bewegungssteuerung aus der Szene, allerdings erweitert um die Begrenzung nach links und rechts. Der eigentlich interessante Teil befindet sich im letzten if-Block. Befindet sich die y-Koordinate der Spielfigur unter der Unterkante der Welt, wird das Spiel beendet. Um das Ende des Spiels kümmert sich die Szene mit der endGame-Methode:

endGame() {
  this.physics.pause();
  this.player.anims.play('turn');
  this.scene.start('GameOver');
}

Die Methode pausiert die Physik-Engine, setzt die Spielfigur auf die neutrale Frontalansicht und wechselt mit einem Aufruf der start-Methode der scene-Eigenschaft in die GameOver-Szene.

Je nachdem, wie Sie mit dieser Implementierung die Plattformen platzieren, kann das Spiel damit deutlich herausfordernder werden. Noch interessanter wird es, wenn Sie Elemente integrieren, mit denen die Spielfigur interagieren kann. Davon gibt es generell zwei verschiedene Arten. Sie können Gegenstände platzieren, die die Spielfigur aufsammeln kann, um einen positiven Effekt zu erreichen. Das Sammeln von Punkten oder Power-ups sind Beispiele für solche Elemente. Die zweite Variante sind Gegner. Berührt ein solcher die Spielfigur, nimmt sie Schaden. Auch hier können Sie Ihrer Fantasie freien Lauf lassen und entweder stationäre Gegner implementieren, die nur auf einer Stelle stehen, oder solche, die die Spielfigur verfolgen. Sowohl Gegenstände als auch Gegner implementieren Sie als Sprite, denen Sie Animationen zuordnen können (Abb. 2).

springer_phaser_2

Abb. 2: Das Spiel mit Figur, Gegner und Sammelgegenständen

Für Gegenstände, die die Spielfigur einsammeln kann, ist ein Sprite erforderlich, das an einer Stelle in der Szene positioniert wird. Bei solchen Gegenständen können Sie die Schwerkraft der Physik-Engine mit Hilfe der setAllowGravity-Methode deaktivieren. Entscheidend ist die Kollisionsbehandlung zwischen Spielfigur und Gegenstand. Hier sehen Sie ein Beispiel für eine solche Kollisionsbehandlung:

this.physics.add.overlap(this.player, coins, (player, coin) => {
  coin.destroy();
  this.increaseScore(50);
});

Mit der overlap-Methode fügen Sie eine Routine in Ihre Szene ein, die ausgeführt wird, wenn die Spielfigur mit einem der Coin-Objekte kollidiert. Die destroy-Methode entfernt das Objekt aus der Szene und this.increaseScore fügt die angegebene Anzahl von Punkten auf das Konto der Spielfigur hinzu. Der Overlap wird zwar auch von der Physik-Engine berechnet. Es kommt jedoch zu keiner physikalischen Kollision zwischen beiden Objekten, bei denen eines beispielsweise abprallt oder blockiert wird. Die Spielfigur kann einfach durch die Gegenstände durchlaufen und sammelt sie dabei auf.

Anders ist die Situation bei einem Gegner. Hier nutzen Sie stattdessen die collider-Methode wie in Listing 6.

Listing 6: Gegner vs. Spielfigur

this.physics.add.collider(this.player, this.enemy, (player, enemy) => {
  const playerBottom = player.body!.bottom;
  const enemyTop = enemy.body!.top;
  const playerVelocityY = player.body!.velocity.y;

  const isTouchingFromAbove = playerBottom === enemyTop && playerVelocityY < 0;

  if (isTouchingFromAbove) {
    enemy.destroy();
    this.increaseScore(100);
  } else {
    this.endGame();
  }
});

Bei der Kollision von Spielfigur und Gegner ist es nicht erstrebenswert, dass die Spielfigur einfach weiterläuft. Genau das verhindert die collider-Methode. Von Bedeutung ist hier außerdem, von welcher Seite die Kollision erfolgt. Springt die Spielfigur von oben auf den Gegner, wird dieser aus der Szene entfernt. In allen anderen Fällen ist das Spiel beendet. Hier können Sie wieder beliebige Erweiterungen einfügen. So können Sie pro entferntem Gegner Punkte vergeben oder eine Möglichkeit umsetzen, dass die Spielfigur eine bestimmte Anzahl von Berührungen mit einem Gegner überlebt.

Fazit

Phaser ist eine Game Engine für 2D-Browserspiele, die Ihnen einen einfachen Einstieg bietet. Innerhalb kürzester Zeit können Sie Ihre Spielidee auf den Bildschirm bringen. Dabei unterliegen Sie kaum Einschränkungen, denn Sie können die Schnittstellen von Phaser beliebig erweitern oder auf zusätzliche Pakete aus der Community zugreifen. Zahlreiche Tutorials, Beispielprojekte und die Dokumentation des Frameworks machen Ihnen den Einstieg in die Spieleentwicklung leicht. Aber auch, wenn es darum geht, Ihr Spiel weiterzuentwickeln, finden Sie Hilfestellungen und Ressourcen.

Auch bei der Art des Spiels, das Sie umsetzen möchten, bietet Ihnen Phaser große Freiheit. Das Platformer-Spiel, das Sie hier als Beispiel gesehen haben, ist nur eine von vielen Möglichkeiten. So können Sie von Arcade-Klassikern wie Tetris oder Space Invaders über Kartenspiele bis zu isometrischen Open-World-Spielen nahezu alles umsetzen.

Monorepos gewinnen in der Softwareentwicklung zunehmend an Bedeutung – dieser Artikel zeigt, wie Gradle als Build-Tool die damit verbundenen Herausforderungen bewältigt und die Vorteile einer zentralisierten Codebasis optimal nutzt.

Softwareentwickler:innen sind vertraut mit Diskussionen über Teststrategien, Codequalität oder Architekturen. Mittlerweile taucht zunehmend eine weitere zentrale Frage auf: Wie lässt sich der Code optimal strukturieren, um eine effektive Zusammenarbeit zu ermöglichen?

Typische Projekte bestehen aus Backend und Frontend, ergänzt durch weitere Komponenten wie Bibliotheken, Infrastrukturcode, Schnittstellen und gemeinsam genutzten Codebasen. Der traditionelle Ansatz sieht für jede dieser Komponenten ein separates Repository vor (Multirepo), jeweils mit eigenem Build-Prozess und häufig auch separatem Deployment.

Die Praxis zeigt, dass die Koordination und Zusammenarbeit über mehrere Repositories hinweg komplex und schwierig ist. Das führt dazu, dass Teams nach alternativen Ansätzen suchen, um ihre Entwicklungsprozesse zu optimieren.

Herausforderungen im Multirepo-Ansatz

Typischerweise wächst die Anzahl häufig auf eine Größenordnung von zehn bis fünfzehn einzelner Repositories an. Diese Struktur bringt verschiedene Herausforderungen mit sich, besonders für Teams, die übergreifend für alle Repositories verantwortlich sind.

Änderungen, Updates oder neue Features zu implementieren, die mehrere Repositories betreffen, sind nur ein paar Beispiele, bei denen Schwierigkeiten auftauchen können. Entwickler:innen müssen parallel mehrere Entwicklungsumgebungen verwalten und bei Merge Requests besondere Aufmerksamkeit auf korrekte Reihenfolge, Versionierung und Merge-Prozesse legen.

Abhängigkeiten über verschiedene Repositories hinweg zu synchronisieren, ist ebenso notwendig wie komplex. Der resultierende Koordinationsaufwand ist so hoch, dass es schlimmstenfalls zu Inkonsistenzen kommt.

Das Monorepo

Monorepos sind an sich keine neue Idee und werden oft auch als Shared Codebase bezeichnet. Dabei wird der Code aus mehreren Projekten in einem Repository verwaltet. Große Unternehmen wie Google, Microsoft oder Meta haben sehr große Monorepos und verschiedene Strategien, um sie skalieren zu können.

Diese Art des Monorepo ist allerdings nicht Gegenstand des Artikels, hier geht es um Monorepos im „kleineren“ Stil. Das Ziel besteht darin, die Zusammenarbeit innerhalb des Teams zu verbessern. Folgerichtig lautet die Frage nicht, wie wir den gesamten Organisationscode in einem Repository verwalten, sondern nur die Teile, die vom Team verwendet und verwaltet werden.

Monorepo-Management mit Gradle

Viele aktuelle Projekte basieren auf einer Kombination aus einem Spring-Boot-Backend und einem Frontend-Framework wie Angular. Für diesen Anwendungsfall eignet sich Gradle, ein auf Java basierendes Build-Managment-Tool, besonders gut. Für die Verwaltung einer Monorepo-Struktur bietet Gradle das Multi-Project-Build-Feature [1] an. Gradle bietet darüber hinaus weitere Features, die das Management und die Zusammenarbeit in einem Monorepo erheblich vereinfachen.

Die folgenden Abschnitte beleuchten diese Features anhand von Beispielen aus einem Referenz-Monorepo, das vollständig auf Github [2] verfügbar ist.

Multi-Project Build

Ein Multi-Project Build besteht aus mehreren Subprojekten und einem Root-Projekt. In Listing 1 ist ein Beispiel für eine Multi-Project-Struktur abgebildet. Die Struktur eines Gradle-Monorepos basiert auf zwei wesentlichen Komponenten: Der settings.gradle.kts [2], die die Subprojekte definiert, und den individuellen build.gradle.kts-Dateien [1], die die spezifischen Build-Konfigurationen der jeweiligen Subprojekte enthalten. Moderne Entwicklungsumgebungen wie IntelliJ IDEA unterstützen die Erstellung und Integration von Subprojekten durch automatisierte Prozesse, die auch die entsprechenden Einträge in der settings.gradle.kts vornehmen.

Listing 1: Struktur eines Multi-Project Build

├── .gradle
│   └── ⋮
├── gradle
│   ├── libs.version.toml
│   └── wrapper
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
├── backend
│   └── build.gradle.kts
├── frontend
│   └── build.gradle.kts
└── someLibrary
    └── build.gradle.kts

Bestehende Projekte lassen sich schrittweise in eine Monorepo-Struktur migrieren, da jedes Subprojekt seine eigenständige Build-Konfiguration beibehält. Das ermöglicht es, die einzelnen Repositories sukzessive zu überführen.

Die Vorteile dieser Struktur zeigen sich in verschiedenen Aspekten der Entwicklung:

  • verbesserte Codenavigation über Projektgrenzen hinweg

  • vereinfachte Durchführung von Refactorings

  • vereinfachter Reviewprozess durch zusammenhängende Änderungen in einem einzigen Pull Request

  • Reduzierung von Abhängigkeiten in der Merge-Reihenfolge

Nach erfolgreicher Einrichtung oder Migration der Grundstruktur können weitere Features von Gradle genutzt werden.

Version Catalog

Unterschiedliche Versionen in verschiedenen Repositories zu verwalten, ist eine verbreitete Herausforderung. Die Zusammenfassung in einem Monorepo allein löst diese Problematik zunächst nicht. Hier bietet Gradle seit Version 7.4 mit dem Version Catalog [3] eine effektive Lösung zur zentralen Versionsverwaltung in der gradle/libs.versions.toml-Datei. Listing 2 zeigt einen Auszug aus dem Beispielprojekt. Die Struktur des Version Catalog gliedert sich in vier Hauptbereiche:

  1. [versions]: Definition von Versionsnummern für das Referenzieren durch Abhängigkeiten

  2. [libraries]: Spezifikation der Abhängigkeiten mit direkter oder referenzierter Versionierung

  3. [plugins]: Definition der Gradle-Plug-ins

  4. [bundles]: Gruppierung mehrerer Abhängigkeiten zu logischen Einheiten

Listing 2: Auszug eines Version-Catalogs

[versions] 
springVersion = "3.2.5"

[libraries] 
springStarterWeb = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springVersion" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect"}
springDocOpenApi = { module = "org.springdoc:springdoc-openapi-ui", version = "2.0.0-M1" }

[plugins] 
openapiGenerator = { id = "org.openapitools.generator:openapi-generator-gradle-plugin", version = "7.5.0" }

[bundles] 
springStarterWeb = ["springStarterWeb", "kotlinReflect"]

Die Integration in eine build.gradle.kts ist in Listing 3 dargestellt. Der Zugriff auf den Version Catalog erfolgt dabei über das libs-Objekt. Plug-ins werden mittels alias eingebunden, während Abhängigkeiten direkt über die entsprechenden Sektionen des libs-Objekts referenziert werden.

Obwohl der Version Catalog auch in Multirepo-Strukturen Vorteile bietet, entfaltet er in Monorepos sein volles Potenzial durch die projektübergreifende Synchronisation von Versionen.

Listing 3: Verwendung des Version-Catalogs

plugins {
  alias(libs.plugins.openapiGenerator) (1)
}

dependencies {
  implementation(libs.springDocOpenApi) (2)
  implementation(libs.bundles.springStarterWeb) (3)
}

Zentralisierung der Build-Logik

Mit der wachsenden Anzahl von Projekten in einem Monorepo steigt auch der Umfang duplizierter Build-Logik, vor allem wenn man aus einem Multirepo auf ein Monorepo migriert. Die Grundstruktur einer typischen build.gradle.kts für ein Kotlin-Projekt ist in Listing 4 zu sehen. Standardkonfigurationen wie Repository-Definitionen, Compile-Tasks und Formatierungsoptionen wiederholen sich dabei, wenn in einem Multiprojekt mehrere Kotlin-Subprojekte enthalten sind.

Listing 4: Aufbau einer build.gradle für ein Kotlin-Projekt

plugins {
  // plugin Definitionen
}

repositories {
  mavenCentral()
  // ...
}

tasks.withType<KotlinCompile> {
  // Compiler Optionen
}

ktfmt {
  // Formattier Optionen
}

dependencies {
  // Projekt Abhängigkeiten
}

Convention-Plug-ins bieten hier eine Lösung zur Zentralisierung dieser gemeinsamen Build-Logik. Typischerweise verwendet man dazu einen Composite Build [4]. Dabei können gemeinsame Teile wie Plug-ins, Repositories und Tasks in ein eigenes Gradle-Skript ausgelagert und von den jeweiligen Projekten eingebunden werden. Dazu legt man seine geteilte Build-Logik in einem eigenen Subprojekt namens build-logic mit build.gradle.kts und settings.gradle.kts an.

Die Struktur des build-logic Subprojekts aus dem Beispiel-Repository ist in Listing 5 zu sehen. In Listing 6 wurde der gemeinsame Teil in ein common.build.kotlin-conventions.gradle.kts ausgelagert.

Listing 5: Struktur eines Composite Build

├── build-logic
│   ├── settings.gradle.kts
│   └── kotlin-conventions
│       ├── build.gradle.kts
│       └── src/main/kotlin/common.build.kotlin-conventions.gradle.kts
├── backend
│   └── ⋮
├── ⋮

Listing 6: Convention-Plug-in für Kotlin-Subprojekte

plugins {
  // plugin Definitionen
}
repositories {
  mavenCentral()
  // ...
}
tasks.withType<KotlinCompile> {
  // Compiler Optionen
}
ktfmt {
  // Formattier Optionen
}

In den jeweiligen Subprojekten kann das Plug-in (Kasten: „Gradle-Plug-ins“) wie in Listing 7 eingebunden werden. Die Subprojekte müssen jetzt nur noch ihre individuellen Konfigurationen und Abhängigkeiten definieren. Dieser Ansatz reduziert Duplikation und vereinfacht es, neue Subprojekte zu initialisieren.

Listing 7: Verwendung eines Convention-Plug-ins

plugins {
  id("common.build.kotlin-conventions")
}

dependencies {
  // Projektabhängigkeiten
}

Gradle-Plug-ins

Gradle-Plug-ins sind ein zentrales Konzept in Gradle, das die Funktionalität und Flexibilität des Build-Systems erweitert. Sie dienen dazu, häufig verwendete Build-Logik zu kapseln und wiederzuverwenden. Plug-ins automatisieren gängige Build-Aufgaben, integrieren externe Tools und passen den Build-Prozess an spezifische Projektanforderungen an.

Integration von geteiltem Code und Schnittstellen

Die Kommunikation zwischen Frontend und Backend über REST-Schnittstellen erfordert eine zuverlässige Synchronisation der API-Definitionen. Der OpenAPI-Generator (Kasten: „OpenAPI-Generator“) bietet hier die Möglichkeit, typsichere Client-Libraries für das Frontend zu generieren.

In Multirepo-Strukturen bedarf es für diesen Prozess an zusätzlicher Koordination: OpenAPI-Definitionen müssen zwischen Repositories synchronisiert werden, sei es durch lokales Auschecken oder durch Integration in Build-Pipelines. Die Monorepo-Struktur vereinfacht diesen Prozess, da Subprojekte direkt auf die Artefakte anderer Subprojekte zugreifen können. In Listing 8 ist ein Auszug aus dem Beispiel-Repository zu sehen. Das generierte Artefakt kann dann von einem anderen Subprojekt direkt verwendet werden.

Listing 8: Verwendung des OpenAPI-Generators

// server-api/build.gradle.kts
plugins { alias(libs.plugins.openApiGenerator) }

val angularBindingPath = layout.buildDirectory.dir("generated/$angularBindingName")
val openApiJsonFile = "$projectDir/src/main/resources/openapi.json"

openApiGenerate {
  generatorName = "typescript-angular"
  inputSpec = openApiJsonFile
  outputDir = angularBindingPath.map { it.asFile.path }
}

OpenAPI-Generator

Der OpenAPI-Generator ermöglicht die automatisierte Erstellung von API-Client-Libraries [5] für verschiedene Programmiersprachen, einschließlich JavaScript und TypeScript. Das ermöglicht eine nahtlose Integration in moderne Frontend-Frameworks wie Angular, React oder Vue.js. Dazu wird aus einer OpenAPI-Spezifikation eine Client-Library in der Zielsprache generiert, die dann im Zielprojekt eingebunden werden kann.

Gradle als universelles Build-Tool

Die Verwendung von Gradle in einem Monorepo mag zunächst hinterfragt werden, da alternative Lösungsansätze existieren. Shellskripte, die manuelle Organisation von Repositories in einem gemeinsamen Verzeichnis oder andere Build-Tools wie Maven oder Nx [6] sind durchaus praktikable Alternativen, insbesondere bei der Integration verschiedener Technologien wie Java und Node.js.

Gradle einzusetzen bietet sich besonders bei Java-lastigen Projekten an. Ein wesentlicher Vorteil liegt in der taskbasierten Architektur. Tasks (Kasten: „Anatomie eines Gradle-Tasks“) repräsentieren dabei definierte Ausführungseinheiten wie Kompilierung, Dokumentationsgenerierung oder Testausführung. Das umfangreiche Plug-in-Ökosystem – beispielsweise der OpenAPI-Generator – erweitert den Funktionsumfang erheblich.

Die Möglichkeit, eigene Tasks zu entwickeln, zu testen und zu veröffentlichen, erhöht die Flexibilität zusätzlich. Ein Beispiel für einen praktischen Anwendungsfall ist die automatisierte Einrichtung der Entwicklungsumgebung, die in Listing 9 zu sehen ist. Die vollständige Implementierung ist im referenzierten Beispiel-Repository verfügbar.

Listing 9: Auszug eines Gradle-Tasks zum automatisierten Aufsetzen des Frontends

tasks.prepareEnv {
  dependsOn(task.npmInstall) // "com.github.node-gradle.node" Plugin
  dependsOn(copyBEClientLibrary) // eigener Task
}

Anatomie eines Gradle-Tasks

Ein Gradle Task definiert sich durch vier Kernkomponenten:

  • Aktionen: ausführbare Logik des Tasks

  • Inputs: erforderliche Eingabeparameter

  • Outputs: generierte Ergebnisse

  • Abhängigkeiten: voraussetzende Tasks

Beispielimplementierung:

tasks.register("hello") {
  group = "Custom"
  description = "A lovely greeting task."
  doLast {
    println("Hello, World!")
  }
}

Build-Optimierung durch Caching

Mit wachsender Komplexität eines Monorepos und zunehmender Anzahl von Gradle-Tasks kann die Build-Zeit signifikant ansteigen. Gradle bietet verschiedene Caching-Mechanismen, die eine vollständige Neukompilierung vermeiden können.

Der Gradle-Build-Lebenszyklus [7] umfasst drei Phasen: Initialisation, Configuration und Execution (Kasten: „Die Gradle-Build-Phasen“). Sowohl Configuration als auch Execution können mittels Caching optimiert werden. Das Beispiel in Listing 10 verdeutlicht den Prozess. Dieser Task benötigt in der Configuration-Phase 2,5 Sekunden und in der Execution-Phase 5 Sekunden, resultierend in einer Gesamtlaufzeit von etwa 7,5 Sekunden pro Ausführung.

Listing 10: Task zum Veranschaulichen der Configuration- und Execution-Phase

tasks.register("example") {
  println("Calculating Configuration Phase")
  Thread.sleep(2500)

  doLast {
    println("Some Execution")
    Thread.sleep(5000)
  }
}

Die Gradle-Build-Phasen

Der Gradle-Build-Prozess läuft in drei aufeinanderfolgenden Phasen ab [8]:

  • Initialisation

  • Lokalisierung der settings.gradle-Datei

  • Ermittlung der am Build beteiligten Projekte

  • Erstellung der Projektinstanzen

  • Configuration

  • Ausführung aller Build-Skripte

  • Konstruktion des Taskgraphen, der die Abhängigkeiten zwischen den Tasks als Directed Acyclic Graph (DAG) darstellt

  • Execution

  • Planung und Durchführung der Tasks

  • Optional: parallele Ausführung entsprechender Abhängigkeiten

Task-Caching

Führt man den Task aus Listing 10 mit dem --info Flag aus, kommt folgende Ausgabe:

> Task :example

Task :example is not up-to-date because:

Task has not declared any outputs despite executing actions.

D. h., um die Execution-Phase cachen zu können, müssen die Ein- und Ausgabe spezifiziert werden [9]. Bei unveränderter Ein- und Ausgabe reduziert sich die Ausführungszeit auf die Configuration-Phase von 2,5 Sekunden. Eine mögliche Anpassung ist in Listing 11 zu sehen. Dabei wird die README.md als Input definiert und das Build-Verzeichnis explizit angegeben. D. h., wenn die README.md nicht geändert und der Task bereits einen Output generiert hat, wird die Execution-Phase übersprungen.

Listing 11: Modifizierter Task mit inputs und outputs

tasks.register("example") {
  inputs.file(file("README.md"))
  outputs.dir(layout.buildDirectory.dir("example"))

  println("Calculating Configuration Phase")
  Thread.sleep(2500)

  doLast {
    println("Some Execution")
    Thread.sleep(5000)
  }
}

Configuration Caching

Configuration Caching [10] kann durch den Parameter --configuration-cache oder über die gradle.properties aktiviert werden. Das kann die Ausführungszeit weiter minimieren. Allerdings erfordert Configuration Caching, bestimmte Richtlinien zu beachten, außerdem unterstützen nicht alle Plug-ins diese Funktion. Detaillierte Informationen finden sich in der offiziellen Dokumentation [11].

Fazit

Die Organisation von Code in einem Monorepo bietet, insbesondere auf Teamebene, verschiedene Vorteile gegenüber einer Multirepo-Struktur. Gradle als Build-Tool unterstützt diesen Ansatz durch die hier vorgestellten Features:

  • Version Catalog ermöglicht es, Abhängigkeiten und deren Versionen zentral zu verwalten.

  • Convention-Plug-ins reduzieren Build-Skript-Duplikationen und standardisieren Projektkonfigurationen.

  • Subprojektabhängigkeiten vereinfachen es, geteilten Code und generierte Artefakte zu teilen.

  • Das Gradle-Task-Ökosystem bietet Flexibilität für projektspezifische Automatisierungen.

  • Caching-Mechanismen optimieren die Build-Performance bei wachsender Projektgröße.

Dennoch sollte die Entscheidung für ein Monorepo im Kontext des jeweiligen Projekts evaluiert werden. Denn der Ansatz kann zwar die Teamzusammenarbeit vereinfachen und den Verwaltungsaufwand reduzieren, er erfordert jedoch initiale Investitionen in die Build-Infrastruktur und die Projektorganisation.

Bei der Entscheidung für ein Monorepo erweist sich Gradle als leistungsfähiges Werkzeug, das durch seine Erweiterbarkeit und das umfangreiche Plug-in-Ökosystem auch komplexe Anforderungen an Monorepo-Strukturen erfüllen kann. Die Kombination aus standardisierten Build-Prozessen und flexibler Anpassbarkeit unterstützt sowohl die initiale Migration als auch das langfristige Wachstum des Monorepos.

Angular 19.2 beinhaltet nun die neue httpResource, die es erlaubt, Daten innerhalb des reaktiven Flusses zu laden. Dieser Artikel zeigt, wie sich dieses – derzeit experimentelle – Feature nutzen lässt. Als Beispiel dient eine einfache Anwendung, die durch Levels, im Stil des Spiels „Super Mario“, scrollt. Jedes Level besteht aus Kacheln, die in vier verschiedenen Stilen zur Verfügung stehen: Overworld, Underground, Underwater und Castle. In unserer Implementierung können Benutzer:innen frei zwischen diesen Stilen wechseln. Abbildung 1 zeigt zum Beispiel das erste Level im Stil Overworld an. Abbildung 2 zeigt dasselbe Level im Stil Underground.

steyer_kolumne_1

Abb. 1: Level 1 im Stil Overworld

steyer_kolumne_2

Abb. 2: Level 1 im Stil Underground

Eine LevelComponent in der Beispielanwendung [1] kümmert sich um das Laden von Leveldateien (JSON) und Kacheln zum Zeichnen der Level unter Nutzung einer httpResource. Zum Rendern und Animieren der Level stützt sich das Beispiel auf eine sehr einfache Engine, die dem Quellcode beiliegt, hier im Artikel jedoch als Blackbox behandelt wird.

HttpClient im Unterbau ermöglicht die Verwendung von Interceptors

Im Unterbau verwendet die neue httpResource derzeit den guten alten HttpClient. Daher muss die Anwendung diesen Service bereitstellen, was normalerweise durch Aufruf von provideHttpClient während des Bootstrappings geschieht. Als Konsequenz ergibt sich, dass die httpResource auch automatisch die registrierten HttpInterceptors aufgreift.

Der HttpClient ist jedoch lediglich ein Implementierungsdetail, das Angular eventuell irgendwann durch eine andere Implementierung ersetzt.

Level-Dateien

Die verschiedenen Levels beschreibt unser Beispiel mit JSON-Dateien, die definieren, welche Kacheln an welchen Koordinaten darzustellen sind (Listing 1).

Listing 1

{
  "levelId": 1,
  "backgroundColor": "#9494ff",
  "items": [
    { "tileKey": "floor", "col": 0, "row": 13, [...] },
    { "tileKey": "cloud", "col": 12, "row": 1, [...] },
    [...]
  ]
}

Diese Koordinaten definieren Positionen innerhalb einer Matrix von Blöcken mit 16x16 Pixeln. Neben diesen Leveldateien liegt eine overview.json vor, die über die Namen der verfügbaren Level informiert.

Ein LevelLoader kümmert sich um das Laden dieser Dateien. Dazu nutzt er die neue httpResource (Listing 2).

Listing 2

@Injectable({ providedIn: 'root' })
export class LevelLoader {
  getLevelOverviewResource(): HttpResourceRef<LevelOverview> {
    return httpResource<LevelOverview>('/levels/overview.json', {
      defaultValue: initLevelOverview,
    });
  }

  getLevelResource(levelKey: () => string | undefined): HttpResourceRef<Level> {
    return httpResource<Level>(() => !levelKey() ? undefined : `/levels/${levelKey()}.json`, {
      defaultValue: initLevel,
    });
  }

 [...]
}

Der erste an httpResource übergebene Parameter repräsentiert die jeweilige URL; der zweite optionale Parameter nimmt ein Objekt mit weiteren Optionen auf. Dieses Objekt erlaubt unter anderem die Definition eines Standardwerts, der zum Einsatz kommt, bevor die Resource geladen wurde.

Die Methode getLevelResource erwartet ein Signal mit einem levelKey, aus dem der Service den Namen der gewünschten Level-Datei ableitet. Dieses schreibgeschützte Signal liegt als Abstraktion vom Typ () => string | undefined vor.

Die von getLevelResource an httpResource übergebene URL ist ein Lambda-Ausdruck, den die Resource automatisch neu auswertet, wenn sich das levelKey-Signal ändert. Im Hintergrund erzeugt httpResource daraus ein berechnetes Signal, das als Auslöser fungiert: Jedes Mal, wenn sich dieser Auslöser ändert, lädt die Resource die URL.

Um das Auslösen der httpResource zu verhindern, muss dieser Lambda-Ausdruck den Wert undefined liefern. Auf diese Weise lässt sich das Laden hinauszögern, bis der levelKey zur Verfügung steht.

Weitere Optionen mit HttpResourceRequest

Um mehr Kontrolle über die ausgehende HTTP-Anfrage zu erhalten, kann der Aufrufer anstelle einer URL einen HttpResourceRequest übergeben (Listing 3).

Listing 3

getLevelResource(levelKey: () => string) {
  return httpResource<Level>(
    () => ({
      url: `/levels/${levelKey()}.json`,
      method: "GET",
      headers: {
        accept: "application/json",
      },
      params: {
        levelId: levelKey(),
      },
      reportProgress: false,
      body: null,
      transferCache: false,
      withCredentials: false,
    }),
    { defaultValue: initLevel }
  );
}

Auch dieser HttpResourceRequest lässt sich durch einen Lambda-Ausdruck, mit dem die httpResource intern ein berechnetes Signal konstruiert, repräsentieren.

Es ist wichtig zu beachten, dass httpResource nur zum Abrufen von Daten gedacht ist, obwohl es auch die Möglichkeit bietet, HTTP-Methoden (HTTP-Verben) jenseits von GET sowie einen body, der als Payload übertragen wird, festzulegen. Diese Optionen ermöglichen das Anbinden von Web APIs, die sich nicht an die Semantiken der HTTP-Verben halten. Standardmäßig konvertet die httpResource den übergebene body nach JSON.

Mit der Option reportProgress kann der Aufrufer Informationen zum Fortschritt der aktuellen Operation anfordern. Dies ist beim Herunterladen größerer Dateien nützlich. Ich gehe weiter unten darauf ein.

Analysieren und Validieren der empfangenen Daten

Standardmäßig erwartet die httpResource Daten in Form von JSON, die zum angegebenen Typparameter passen. Zusätzlich kommt eine Type Assertion zum Einsatz, damit TypeScript das Vorliegen korrekter Typen annimmt. Es ist jedoch möglich, sich in diesen Prozess einzuklinken, um eine benutzerdefinierte Logik für die Validierung des empfangenen Rohwerts und dessen Konvertierung in den gewünschten Typ bereitzustellen. Dazu definiert der Aufrufer eine Funktion über die Eigenschaft map im Optionsobjekt (Listing 4).

Listing 4

getLevelResourceAlternative(levelKey: () => string) {
  return httpResource<Level>(() => `/levels/${levelKey()}.json`, {
    defaultValue: initLevel,
    map: (raw) => {
      return toLevel(raw);
    },
  });
}

Die httpResource konvertiert das empfangene JSON in ein Objekt vom Typ unknown und übergibt es an map. In unserem Beispiel wird eine einfache selbstgeschriebene Funktion toLevel verwendet. Daneben erlaubt map auch das Einbinden von Bibliotheken wie Zod [2], die eine Schemavalidierung durchführen.

Laden von Daten jenseits von JSON

Die httpResource erwartet standardmäßig ein JSON-Dokument, das sie in ein JavaScript-Objekt konvertiert. Daneben bietet sie weitere Methoden, die andere Darstellungsformen liefern:

  • httpResource.text gibt Text zurück

  • httpResource.blob gibt die abgerufenen Daten als Blob zurück

  • httpResource.arrayBuffer gibt die abgerufenen Daten als ArrayBuffer zurück

Um den Einsatz dieser Möglichkeiten zu demonstrieren, fordert das hier besprochene Beispiel ein Bild mit allen möglichen Kacheln als Blob an. Aus diesem Blob leitet es die benötigten Kacheln für den gewählten Level-Stil ab. Abbildung 3 zeigt einen Ausschnitt dieser von [3] übernommenen Tilemap und verdeutlicht, dass die Anwendung durch die Wahl eines horizontalen bzw. vertikalen Offsets zwischen den einzelnen Stilen wechseln kann.

steyer_kolumne_3

Abb. 3: Ausschnitt der im Beispiel verwendeten Tilemap

Zum Laden der Tilemap delegiert ein TilesMapLoader an httpResource.blob (Listing 5).

Listing 5

@Injectable({ providedIn: "root" })
export class TilesMapLoader {
  getTilesMapResource(): HttpResourceRef<Blob | undefined> {
    return httpResource.blob({
      url: "/tiles.png",
      reportProgress: true,
    });
  }
}

Diese Resource fordert auch Fortschrittsinformationen an und greift das Beispiel auf, um die Fortschrittsinformationen links neben den Dropdown-Feldern anzuzeigen.

Alles zusammen: reaktiver Fluss

Die in den letzten Abschnitten beschriebenen httpResources lassen sich nun zum reaktiven Graphen der Anwendung zusammenfügen (Abb. 4).

steyer_kolumne_4

Abb. 4: Reaktiver Fluss von ngMario

Die Signale levelKey, style und animation repräsentieren die Benutzereingaben. Die ersten beiden entsprechen den Dropdown-Feldern im oberen Bereich der Anwendung. Das Signal animation enthält einen Boolean, der angibt, ob die Animation per Klick auf die Schaltfläche Toggle Animation gestartet wurde (siehe Screenshots oben).

Die tilesResource ist eine klassische Resource, die die einzelnen Kacheln für den gewählten Stil aus der Tilemap ableitet. Dazu delegiert sie im Wesentlichen an eine Funktion der hier als Blackbox behandelten Game Engine.

Das Rendering wird durch einen Effect angestoßen, zumal wir das Level nicht direkt mittels Datenbindung zeichnen können. Er zeichnet oder animiert das Level auf einem Canvas, den die Anwendung als Signal-basiertes viewChild abruft. Angular ruft den Effect immer dann auf, wenn sich das Level (bereitgestellt durch die levelResource), der Stil, das Flag animation oder der Canvas ändert.

Ein tilesMapProgress-Signal verwendet die von tilesMapResource bereitgestellten Fortschrittsinformationen, um anzugeben, wie viel von der Tilesmap bereits heruntergeladen wurde. Zum Laden der Liste mit den verfügbaren Levels verwendet das Beispiel eine levelOverviewResource, die nicht direkt mit dem bisher besprochenen reaktiven Graphen verbunden ist.

Listing 6 zeigt die Implementierung dieses reaktiven Flusses in Form von Feldern der LevelComponent.

Listing 6

export class LevelComponent implements OnDestroy {
  private tilesMapLoader = inject(TilesMapLoader);
  private levelLoader = inject(LevelLoader);

  canvas = viewChild<ElementRef<HTMLCanvasElement>>("canvas");

  levelKey = linkedSignal<string | undefined>(() => this.getFirstLevelKey());
  style = signal<Style>("overworld");
  animation = signal(false);

  tilesMapResource = this.tilesMapLoader.getTilesMapResource();
  levelResource = this.levelLoader.getLevelResource(this.levelKey);
  levelOverviewResource = this.levelLoader.getLevelOverviewResource();

  tilesResource = createTilesResource(this.tilesMapResource, this.style);

  tilesMapProgress = computed(() =>
    calcProgress(this.tilesMapResource.progress())
  );

  constructor() {
    [...]
    effect(() => {
      this.render();
    });
  }

  reload() {
    this.tilesMapResource.reload();
    this.levelResource.reload();
  }

  private getFirstLevelKey(): string | undefined {
    return this.levelOverviewResource.value()?.levels?.[0]?.levelKey;
  }

  [...]
}

Die Verwendung eines linkedSignal für den levelKey ermöglicht es uns, das erste Level als Standardwert zu verwenden, sobald die Liste mit den Levels geladen wurde. Der Helfer getFirstLevelKey gibt diesen aus der levelOverviewResource zurück.

Der Effect ruft die genannten Werte aus dem jeweiligen Signal ab und übergibt sie an die Funktion animateLevel oder rederLevel der Engine (Listing 7).

Listing 7

private render() {
  const tiles = this.tilesResource.value();
  const level = this.levelResource.value();
  const canvas = this.canvas()?.nativeElement;
  const animation = this.animation();

  if (!tiles || !canvas) {
    return;
  }

  if (animation) {
    animateLevel({
      canvas,
      level,
      tiles,
    });
  } else {
    renderLevel({
      canvas,
      level,
      tiles,
    });
  }
}

Resources und fehlende Parameter

Die im diskutierten Diagramm dargestellte tilesResource delegiert lediglich an die asynchrone Funktion extractTiles, die die Engine ebenfalls bereitstellt (Listing 8).

Listing 8

function createTilesResource(
  tilesMapResource: HttpResourceRef<Blob | undefined>,
  style: () => Style
) {
  const tilesMap = tilesMapResource.value();

  // undefined prevents the resource from beeing triggered
  const request = computed(() =>
    !tilesMap
      ? undefined
      : {
          tilesMap: tilesMap,
          style: style(),
        }
  );

  return resource({
    request,
    loader: (params) => {
      const { tilesMap, style } = params.request!;
      return extractTiles(tilesMap, style);
    },
  });
}

Diese einfache Resource enthält ein interessantes Detail: Vor dem Laden der Tilemap hat die tilesMapResource den Wert undefined. Ohne eine tilesMap können wir jedoch extractTiles nicht aufrufen. Genau das berücksichtigt das Signal request: Es liefert undefined, wenn noch keine tilesMap vorliegt, sodass die Resource ihren Loader nicht anstößt.

Anzeige des Fortschritts

Oben wurde die tilesMapResource so konfiguriert, dass sie über ihr progress-Signal Informationen über den Downloadfortschritt liefert. Ein berechnetes Signal in der LevelComponent projiziert es in einen String für die Anzeige (Listing 9).

Listing 9

function calcProgress(progress: HttpProgressEvent | undefined): string {
  if (!progress) {
    return "-";
  }

  if (progress.total) {
    const percent = Math.round((progress.loaded / progress.total) * 100);
    return percent + "%";
  }

  const kb = Math.round(progress.loaded / 1024);
  return kb + " KB";
}

Wenn der Server über die Dateigröße informiert, berechnet diese Funktion einen Prozentwert für den bereits heruntergeladenen Anteil. Andernfalls liefert sie nur die Anzahl der bereits heruntergeladenen Kilobyte zurück. Vor dem Start des Downloads gibt es keine Fortschrittsinformationen. In diesem Fall kommt lediglich ein Bindestrich zum Einsatz.

Um diese Funktion zu testen, bietet es sich an, die Netzwerkverbindung des Browsers in der Entwickler-Konsole zu drosseln sowie die Schaltfläche Reload in der Anwendung zu drücken, um die Resources anzuweisen, die Daten erneut zu laden.

Status, Header, Error und mehr

Für den Fall, dass die Anwendung den Statuscode oder die Header der HTTP-Antwort benötigt, stellt die httpResource entsprechende Signals bereit:

console.log(this.levelOverviewResource.status());
console.log(this.levelOverviewResource.statusCode());
console.log(this.levelOverviewResource.headers()?.keys());

Darüber hinaus stellt die httpResource alles zur Verfügung, was auch von gewöhnlichen Resources bekannt ist, darunter ein error-Signal, das über einen eventuell aufgetretenen Fehler informiert sowie die Möglichkeit, den value zu aktualisieren, der als lokale Arbeitskopie vorliegt.

Zusammenfassung

Die neue httpResource ist ein weiterer Baustein, der Angulars neue Signal-Story ergänzt. Sie ermöglicht das Laden von Daten innerhalb des reaktiven Graphen. Derzeit verwendet sie den HttpClient als Implementierungsdetail, der eventuell zu einem späteren Zeitpunkt durch eine andere Lösung ersetzt wird.

Während die HTTP-Resource auch das Abrufen von Daten mit HTTP-Verben jenseits von GET ermöglicht, ist sie nicht für das Zurückschreiben von Daten zum Server ausgelegt. Diese Aufgabe gilt es weiterhin auf herkömmliche Weise zu erledigen.

Desktop Tablet Mobile
Desktop Tablet Mobile