paint-brush
MVP-Spieleentwicklung mit Flutter und Flameby@leobit
408
408

MVP-Spieleentwicklung mit Flutter und Flame

Leobit28m2024/06/06
Read on Terminal Reader

MVPs (Minimum Viable Products) erfreuen sich in der Spielebranche zunehmender Beliebtheit. Mit MVPs können Sie in kurzer Zeit und mit begrenztem Budget eine App mit grundlegenden Funktionen erstellen. Mithilfe von Flame, einer robusten und Open-Source-Spiele-Engine, die auf Flutter basiert, können Sie atemberaubende 2D-Spiele erstellen.
featured image - MVP-Spieleentwicklung mit Flutter und Flame
Leobit HackerNoon profile picture
0-item

Laut einer aktuellen Umfrage sind nur zwei von fünf Startups profitabel. Ein MVP (Minimum Viable Product) erhöht die Rentabilitätschancen eines Startups erheblich, da es solchen Unternehmen ermöglicht, frühzeitig Benutzerfeedback zu sammeln, ohne das gesamte Budget für eine App mit voller Funktionalität auszugeben.


Mit MVP können Sie in kurzer Zeit und mit begrenztem Budget eine App mit grundlegenden Funktionen erstellen, Benutzerfeedback sammeln und die Lösung entsprechend diesem Feedback mit Ihrem Entwicklungsteam weiter ausbauen.


MVPs erfreuen sich in der Spielebranche zunehmender Beliebtheit. Heute erkunden wir die Besonderheiten der schnellen MVP- Entwicklung mit Flutter und Flame, einer hervorragenden Kombination für die Entwicklung plattformübergreifender minimal funktionsfähiger Produkte.

Warum Flutter und Flame wählen?

Flutter, eine funktionsreiche und sichere Plattform für plattformübergreifende Entwicklung , hat die Welt der mobilen Apps im Sturm erobert und ihre Reichweite geht weit über die Benutzeroberfläche hinaus. Mithilfe von Flame, einer robusten und quelloffenen Spiele-Engine, die auf Flutter aufbaut, können Sie atemberaubende 2D-Spiele erstellen, die reibungslos auf Android-, iOS-, Web- und Desktop-Geräten laufen.


Flutter ist auch aufgrund seiner integrierten Funktionen, die die schnelle Entwicklung von Lösungen ermöglichen, die die grundlegende Funktionalität auf verschiedenen Geräten darstellen, zu einer beliebten Lösung für den Aufbau von MVPs für Spiele geworden. Insbesondere ermöglichen verschiedene Vorteile und integrierte Funktionen von Flutter:


  • Erstellen Sie ein Produkt mit einer gemeinsamen Codebasis für verschiedene Plattformen, einschließlich Android und iOS. Das ist viel schneller und kostengünstiger, als separate native Apps für verschiedene Plattformen zu erstellen. Es gibt auch bestimmte Vorgehensweisen zum Erstellen von Flutter-Web-Apps mit derselben Codebasis.


  • Erstellen Sie flexible Benutzeroberflächen mit vorgefertigten Widgets und Standardanimationen, was die Entwicklungsgeschwindigkeit steigert, einen der wichtigsten Faktoren bei der MVP-Entwicklung.


  • Flutter bietet eine Hot-Reload-Funktion, mit der Entwickler die im App-Code vorgenommenen Änderungen gleichzeitig auf dem Bildschirm anzeigen können, was für mehr Flexibilität bei der MVP-Entwicklung sorgt. Diese Funktion vereinfacht Iteration und Experimente erheblich und ermöglicht es Entwicklern, schnell verschiedene Mechaniken und Visualisierungen auszuprobieren.


  • Für die Entwicklung eines minimal funktionsfähigen Produkts ist normalerweise nur eine minimale Anzahl an Ressourcen erforderlich. Flutter erfüllt diese Anforderung vollständig, da die standardmäßige Integration von Flutter mit Firebase die Komplexität der serverseitigen Programmierung erheblich reduziert.


Flutter verbraucht nicht viele Rechenressourcen und ermöglicht die einfache Einrichtung plattformübergreifender Anwendungen.


Die auf der Kombination von Flutter und Flame basierende MVP-App ist eine zuverlässige und dennoch relativ einfach zu entwickelnde Lösung. Sie kompiliert direkt in nativen Code und sorgt so für reibungsloses Gameplay und Reaktionsfähigkeit. Sie können Ihr Game-MVP einmal entwickeln und auf verschiedenen Plattformen bereitstellen, was Zeit und Ressourcen spart. Flutter und Flame handhaben die Plattformunterschiede im Hintergrund.


Darüber hinaus verfügen beide Technologien über lebendige Communities mit ausführlicher Dokumentation, Tutorials und Codebeispielen. Das bedeutet, dass Ihnen nie die Antwort oder Inspiration ausgehen wird.

Was also kann Flame tun?

Flame bietet ein komplettes Toolset, um MVP-Spielfunktionen in kurzer Zeit und ohne übermäßigen Ressourceneinsatz zu erstellen. Dieses plattformübergreifende Modellierungsframework bietet Tools für eine Vielzahl unterschiedlicher Anwendungsfälle:


  • Sprites und Animationen: Sie können Ihre Sprites schnell erstellen oder aus verschiedenen Online-Bibliotheken verwenden. Flame unterstützt auch Skelettanimationen, mit denen Sie komplexere und realistischere Animationen erstellen können.


  • Kollisionserkennung: Es verfügt über ein integriertes Kollisionserkennungssystem, mit dem Sie ganz einfach Spiele mit Ihrer eigenen Physik erstellen können. Sie können die Kollisionserkennung verwenden, um Plattformen, Wände, Sammlerstücke und andere Dinge zu erstellen, mit denen Ihre Spielfiguren interagieren können.


  • Physiksimulationen: Flame unterstützt auch Physiksimulationen, mit denen Sie dynamischere und spannendere Spielmechaniken erstellen können. Sie können Physiksimulationen verwenden, um Dinge wie Schwerkraft, Springen und Hüpfen zu erstellen.


  • Audio und Soundeffekte: Sie können Audio verwenden, um Hintergrundmusik, Soundeffekte (wie Schlagen, Springen usw.) und sogar Sprachausgabe zu erstellen.


  • Statusverwaltung: Flame bietet eine Reihe von Funktionen zur Verwaltung des Status Ihres Spiels. Dazu gehören Punktestand, Levelverwaltung und Spielerdaten.


  • Eingabegeräte: Flame unterstützt verschiedene Eingabegeräte wie Touchscreens, Tastaturen und Gamecontroller. Dies macht es zu einer großartigen Option für die Entwicklung von Spielen für eine Vielzahl von Plattformen.


  • Parallax-Scrolling: Es unterstützt Parallax-Scrolling, das Ihrer Spielwelt Tiefe und Immersion verleihen kann. Parallax-Scrolling erzeugt die Illusion von Tiefe, indem verschiedene Ebenen des Hintergrunds mit unterschiedlicher Geschwindigkeit bewegt werden.


  • Partikelsysteme: Flame unterstützt auch Partikelsysteme, mit denen sich verschiedene visuelle Effekte wie Explosionen, Rauch und Regen erzeugen lassen.


  • Multiplayer-Gameplay: Dies ermöglicht es den Spielern, in Echtzeit miteinander zu konkurrieren oder zusammenzuarbeiten.


Die meisten der oben genannten Funktionen sind für viele Spiele unverzichtbar und sollten auch in der MVP-Entwicklungsphase nicht übersehen werden. Was wirklich wichtig ist, ist, dass Flame die Geschwindigkeit der Entwicklung der oben genannten Funktionen erheblich steigert, sodass Sie solche Funktionen bereits in den frühesten Produktversionen veröffentlichen können.

Lass es uns versuchen

Anstatt über Flame zu sprechen, erstellen wir nun mit diesem Framework ein MVP, das die grundlegenden Funktionen unseres eigenen Spiels enthält. Bevor wir beginnen, müssen Sie Flutter 3.13 oder höher, Ihre bevorzugte IDE und Ihr bevorzugtes Testgerät, installiert haben.

Eine Idee

Dieses Spiel ist von Chrome Dino inspiriert. Ah, der berühmte Dino Run! Es ist mehr als nur ein Spiel von Chrome. Es ist ein beliebtes Easter Egg, das im Offline-Modus des Browsers versteckt ist.


Unser Projekt wird den folgenden Spielablauf haben:

  • Sie spielen Jack, einen abenteuerlustigen Typen, der endlos durch einen dunklen Wald rennt.
  • Die Steuerung ist minimal: Tippen Sie auf die Leertaste oder klicken Sie auf den Bildschirm, um zu springen.
  • Das Spiel beginnt langsam, steigert aber allmählich die Geschwindigkeit und hält Sie auf Trab.
  • Ihr Ziel ist einfach: Vermeiden Sie die Hindernisse und laufen Sie so weit wie möglich, und sammeln Sie dabei Punkte.


Und er wird „Forest Run!“ heißen!

Bereiten Sie sich vor

Erstellen Sie ein leeres Flutter-Projekt, wie Sie es jedes Mal tun, wenn Sie eine neue App starten. Zu Beginn müssen wir Abhängigkeiten in pubspec.yaml für unser Projekt festlegen. Beim Schreiben dieses Beitrags ist die neueste Version von Flame 1.14.0. Lassen Sie uns jetzt auch alle Asset-Pfade definieren, damit wir später nicht mehr auf diese Datei zurückgreifen müssen. Und legen Sie Bilder in das Verzeichnis asset/images/. Wir müssen es hier ablegen, da Flame genau diesen Pfad scannt:


 environment: sdk: '>=3.2.3 <4.0.0' flutter: '>=3.13.0' dependencies: flutter: sdk: flutter flame: ^1.14.0 flutter: uses-material-design: true assets: - assets/images/ - assets/images/character/ - assets/images/background/ - assets/images/forest/ - assets/images/font/


Denken Sie daran, alle Bilder unter „assets/images/“ abzulegen, da Flame andere Verzeichnisse nicht analysiert.


Für jedes Spiel benötigen Sie eine Menge Bilder. Aber was, wenn Sie kein gutes Designtalent sind? Glücklicherweise gibt es viele Open-Source-Assets, die Sie für Ihre Projekte verwenden können. Die Assets für dieses Spiel stammen von itch.io. Wir werden diese Ressourcen für unser Projekt verwenden:



Sie können diese Links besuchen oder einfach vorbereitete Assets (LINK ZUM ASSETS-ARCHIV) für dieses Projekt herunterladen und den gesamten Inhalt in Ihr Projekt kopieren.


Flame hat eine ähnliche Philosophie wie Flutter. In Flutter ist alles ein Widget; in Flame ist alles eine Komponente, sogar das ganze Spiel. Jede Komponente kann 2 Methoden überschreiben: onLoad() und update(). onLoad() wird nur einmal aufgerufen, wenn die Komponente in den ComponentTree eingebunden wird, und update() wird bei jedem Frame ausgelöst. Sehr ähnlich zu initState() und build() von StatefulWidget in Flutter.


Schreiben wir nun etwas Code. Erstellen Sie eine Klasse, die FlameGame erweitert und alle unsere Assets in den Cache lädt.


 class ForestRunGame extends FlameGame { @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); } }


Als nächstes verwenden Sie ForestRunGame in main.dart. Sie können auch Methoden von Flame.device verwenden, um die Geräteausrichtung zu konfigurieren. Und es gibt GameWidget, das als Brücke zwischen Widgets und Komponenten dient.


 Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Flame.device.fullScreen(); await Flame.device.setLandscape(); runApp(GameWidget(game: ForestRunGame())); }


An diesem Punkt können wir das Spiel bereits starten, aber es wird nur ein schwarzer Bildschirm angezeigt. Also müssen wir unsere Komponenten hinzufügen.

Dunkler Wald

Wir werden den Wald in zwei Komponenten unterteilen: Hintergrund und Vordergrund. Zuerst kümmern wir uns um den Hintergrund. Haben Sie schon einmal durch eine Seite gescrollt, die sich dynamisch anfühlte? Als ob Sie durch mehrere Ansichten gleichzeitig gescrollt hätten? Das ist ein Parallaxeffekt, der auftritt, wenn sich die verschiedenen Elemente einer Seite mit unterschiedlicher Geschwindigkeit bewegen und so einen 3D-Tiefeneffekt erzeugen.


Wie Sie sich vorstellen können, verwenden wir für unseren Hintergrund eine Parallaxe. Erweitern Sie ParallaxComponent und richten Sie mit ParallaxImageData einen Stapel Bilder ein. Außerdem gibt es baseVelocity für die Geschwindigkeit der ersten Ebenen und velocityMultiplierDelta, das für den relativen Geschwindigkeitsunterschied zwischen den Ebenen steht. Und als Letztes konfigurieren Sie das Prioritätsfeld (Z-Index), um es hinter andere Komponenten zu verschieben.


 class ForestBackground extends ParallaxComponent<ForestRunGame> { @override Future<void> onLoad() async { priority = -10; parallax = await game.loadParallax( [ ParallaxImageData('background/plx-1.png'), ParallaxImageData('background/plx-2.png'), ParallaxImageData('background/plx-3.png'), ParallaxImageData('background/plx-4.png'), ParallaxImageData('background/plx-5.png'), ], baseVelocity: Vector2.zero(), velocityMultiplierDelta: Vector2(1.4, 1.0), ); } }


Der Hintergrund ist fertig; jetzt ist es Zeit, den Vordergrund hinzuzufügen. Erweitern Sie die PositionComponent, damit wir den Boden am unteren Bildschirmrand ausrichten können. Wir benötigen außerdem das HasGameReference-Mixin, um auf den Spiel-Cache zuzugreifen.


Um einen Boden zu erstellen, müssen Sie das Bodenblockbild einfach mehrmals in die Zeile einfügen. In Flame werden Bildkomponenten Sprites genannt. Ein Sprite ist ein Bereich eines Bildes, der im Canvas gerendert werden kann. Es kann das gesamte Bild darstellen oder eines der Teile sein, aus denen ein Spritesheet besteht.


Denken Sie auch daran, dass die X-Achse nach rechts und die Y-Achse nach unten ausgerichtet ist. Der Mittelpunkt der Achsen befindet sich in der linken oberen Ecke des Bildschirms.



 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { static final blockSize = Vector2(480, 96); late final Sprite groundBlock; late final Queue<SpriteComponent> ground; @override void onLoad() { super.onLoad(); groundBlock = Sprite(game.images.fromCache('forest/ground.png')); ground = Queue(); } @override void onGameResize(Vector2 size) { super.onGameResize(size); final newBlocks = _generateBlocks(); ground.addAll(newBlocks); addAll(newBlocks); y = size.y - blockSize.y; } List<SpriteComponent> _generateBlocks() { final number = 1 + (game.size.x / blockSize.x).ceil() - ground.length; final lastBlock = ground.lastOrNull; final lastX = lastBlock == null ? 0 : lastBlock.x + lastBlock.width; return List.generate( max(number, 0), (i) => SpriteComponent( sprite: groundBlock, size: blockSize, position: Vector2(lastX + blockSize.x * i, y), priority: -5, ), growable: false, ); } }


Und als Letztes fügen Sie diese Komponenten zu unserem ForestRunGame hinzu.


 class ForestRunGame extends FlameGame { late final foreground = ForestForeground(); late final background = ForestBackground(); @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); add(foreground); add(background); } }


Versuchen Sie nun, das Spiel zu starten. An diesem Punkt haben wir bereits unseren Wald.


Ein Fremder im Wald

Der Wald sieht gut aus, ist aber im Moment nur ein Bild. Also werden wir Jack erstellen, der unter Anleitung des Spielers durch diesen Wald rennt. Anders als Bäume und Boden braucht der Spieler Animationen, um sich lebendig zu fühlen. Wir haben Sprite für Bodenblöcke verwendet, aber für Jack werden wir SpriteAnimation verwenden. Wie funktioniert das? Nun, es ist alles ganz einfach, Sie müssen nur eine Folge von Sprites in einer Schleife abspielen. Unsere Laufanimation hat beispielsweise 8 Sprites, die sich mit einer kleinen Zeitlücke gegenseitig ersetzen.



Jack kann rennen, springen und untätig sein. Um seine Zustände darzustellen, können wir eine PlayerState-Aufzählung hinzufügen. Erstellen Sie dann einen Player, der SpriteAnimationGroupComponent erweitert, und übergeben Sie PlayerState als generisches Argument. Diese Komponente hat ein Animationsfeld, in dem Animationen für jeden PlayerState gespeichert sind, und ein aktuelles Feld, das den aktuellen Zustand des Players darstellt, der animiert werden muss.


 enum PlayerState { jumping, running, idle } class Player extends SpriteAnimationGroupComponent<PlayerState> { @override void onLoad() { super.onLoad(); animations = { PlayerState.running: SpriteAnimation.fromFrameData( game.images.fromCache('character/run.png'), SpriteAnimationData.sequenced( amount: 8, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(23, 34), ), ), PlayerState.idle: SpriteAnimation.fromFrameData( game.images.fromCache('character/idle.png'), SpriteAnimationData.sequenced( amount: 12, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(21, 35), ), ), PlayerState.jumping: SpriteAnimation.spriteList( [ Sprite(game.images.fromCache('character/jump.png')), Sprite(game.images.fromCache('character/land.png')), ], stepTime: 0.4, loop: false, ), }; current = PlayerState.idle; } }


Die Spielerzustände sind bereit. Jetzt müssen wir dem Spieler eine Größe und Position auf dem Bildschirm zuweisen. Ich werde seine Größe auf 69 x 102 Pixel festlegen, aber Sie können sie gerne nach Belieben ändern. Für die Position müssen wir die Koordinaten des Bodens kennen. Durch Hinzufügen des HasGameReference-Mixins können wir auf das Vordergrundfeld zugreifen und seine Koordinaten abrufen. Lassen Sie uns nun die Methode onGameResize überschreiben, die jedes Mal aufgerufen wird, wenn die Größe der Anwendung geändert wird, und dort die Position von Jack festlegen.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { static const startXPosition = 80.0; Player() : super(size: Vector2(69, 102)); double get groundYPosition => game.foreground.y - height + 20; // onLoad() {...} with animation setup @override void onGameResize(Vector2 size) { super.onGameResize(size); x = startXPosition; y = groundYPosition; } }


Fügen Sie den Spieler wie zuvor zu unserem Spiel hinzu.


 class ForestRunGame extends FlameGame { // Earlier written code here... late final player = Player(); @override Future<void> onLoad() async { // Earlier written code here... add(player); } }


Wenn Sie das Spiel starten, sehen Sie, dass Jack bereits im Wald ist!


Lauf, Jack, lauf!

Unser Spiel hat drei Zustände: Intro, Play und Game Over. Wir fügen also die Enumeration GameState hinzu, die diese Zustände repräsentiert. Damit Jack rennen kann, benötigen wir die Variablen Geschwindigkeit und Beschleunigung. Außerdem müssen wir die zurückgelegte Strecke berechnen (wird später verwendet).


Wie bereits erwähnt, verfügt die Komponente über zwei Hauptmethoden: onLoad() und update(). Die Methode onLoad haben wir bereits einige Male verwendet. Lassen Sie uns nun über update() sprechen. Diese Methode verfügt über einen Parameter namens dt. Er stellt die Zeit dar, die seit dem letzten Aufruf von update() vergangen ist.


Um die aktuelle Geschwindigkeit und die zurückgelegte Strecke zu berechnen, verwenden wir die Methode update() und einige grundlegende Kinematikformeln:

  • Entfernung = Geschwindigkeit * Zeit;
  • Geschwindigkeit = Beschleunigung * Zeit;


 enum GameState { intro, playing, gameOver } class ForestRunGame extends FlameGame { static const acceleration = 10.0; static const maxSpeed = 2000.0; static const startSpeed = 400.0; GameState state = GameState.intro; double currentSpeed = 0; double traveledDistance = 0; // Earlier written code here... @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } }


Tatsächlich werden wir einen Trick anwenden, um die Entwicklung einfacher zu machen: Jack bleibt stabil, aber der Wald bewegt sich auf Jack zu. Unser Wald muss also Spielgeschwindigkeit anwenden.


Für den Parallax-Hintergrund müssen wir nur die Spielgeschwindigkeit übergeben. Der Rest wird automatisch erledigt.


 class ForestBackground extends ParallaxComponent<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); parallax?.baseVelocity = Vector2(game.currentSpeed / 10, 0); } }


Für den Vordergrund müssen wir jeden Bodenblock verschieben. Außerdem müssen wir prüfen, ob der erste Block in der Warteschlange den Bildschirm verlassen hat. Wenn ja, entfernen Sie ihn und platzieren Sie ihn am Ende der Warteschlange.


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); final shift = game.currentSpeed * dt; for (final block in ground) { block.x -= shift; } final firstBlock = ground.first; if (firstBlock.x <= -firstBlock.width) { firstBlock.x = ground.last.x + ground.last.width; ground.remove(firstBlock); ground.add(firstBlock); } } }


Alles ist bereit, bis auf einen Auslöser. Wir möchten die Ausführung per Klick starten. Unsere Ziele sind sowohl Mobilgeräte als auch Desktops, daher möchten wir Bildschirmberührungen und Tastaturereignisse verarbeiten.


Glücklicherweise gibt es bei Flame eine Möglichkeit, dies zu tun. Fügen Sie einfach ein Mixin für Ihren Eingabetyp hinzu. Für die Tastatur sind es KeyboardEvents und TapCallbacks für das Tippen auf den Bildschirm. Diese Mixins geben Ihnen die Möglichkeit, verwandte Methoden zu überschreiben und Ihre Logik bereitzustellen.


Das Spiel muss starten, wenn der Benutzer die Leertaste drückt oder auf den Bildschirm tippt.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { start(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { start(); } void start() { state = GameState.playing; player.current = PlayerState.running; currentSpeed = startSpeed; traveledDistance = 0; } }


Als Ergebnis kann Jack nun nach dem Klicken laufen.


Oh nein, ein Busch!

Jetzt wollen wir Hindernisse auf der Straße haben. In unserem Fall werden sie als giftige Büsche dargestellt. Der Busch ist nicht animiert, also können wir SpriteComponent verwenden. Außerdem brauchen wir eine Spielreferenz, um auf seine Geschwindigkeit zuzugreifen. Und noch etwas: Wir wollen die Büsche nicht einzeln erscheinen lassen, denn dieser Ansatz kann dazu führen, dass Jack eine Reihe von Büschen einfach nicht mit einem Sprung passieren kann. Es handelt sich um eine Zufallszahl aus dem Bereich, der von der aktuellen Spielgeschwindigkeit abhängt.


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { late double gap; Bush() : super(size: Vector2(200, 84)); bool get isVisible => x + width > 0; @override Future<void> onLoad() async { x = game.size.x + width; y = -height + 20; gap = _computeRandomGap(); sprite = Sprite(game.images.fromCache('forest/bush.png')); } double _computeRandomGap() { final minGap = width * game.currentSpeed * 100; final maxGap = minGap * 5; return (Random().nextDouble() * (maxGap - minGap + 1)).floor() + minGap; } @override void update(double dt) { super.update(dt); x -= game.currentSpeed * dt; if (!isVisible) { removeFromParent(); } } }


Wer pflanzt Büsche? Natürlich die Natur. Lasst uns eine Natur schaffen, die unsere Buschgeneration bewirtschaftet.


 class Nature extends Component with HasGameReference<ForestRunGame> { @override void update(double dt) { super.update(dt); if (game.currentSpeed > 0) { final plant = children.query<Bush>().lastOrNull; if (plant == null || (plant.x + plant.width + plant.gap) < game.size.x) { add(Bush()); } } } }


Fügen wir nun „Natur“ zu unserem Waldvordergrund hinzu.


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... late final Nature nature; @override void onLoad() { // Earlier written code here... nature = Nature(); add(nature); }


Jetzt hat unser Wald Büsche. Aber Moment, Jack rennt einfach durch sie hindurch. Warum passiert das? Weil wir das Schlagen noch nicht implementiert haben.


Hier hilft uns Hitbox. Hitbox ist eine weitere Komponente im Komponentenzoo von Flame. Es kapselt die Kollisionserkennung und gibt Ihnen die Möglichkeit, diese mit benutzerdefinierter Logik zu handhaben.


Fügen Sie einen für Jack hinzu. Denken Sie daran, dass die Position der Komponente ihre linke und rechte Ecke und nicht ihre Mitte bestimmt. Und mit der Größe erledigen Sie den Rest.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(2, 2), size: Vector2(60, 100), ), ); } }


Und eine für den Busch. Hier setzen wir den Kollisionstyp zur Optimierung auf passiv. Standardmäßig ist der Typ aktiv, was bedeutet, dass Flame prüft, ob diese Hitbox mit jeder anderen Hitbox kollidiert. Wir haben nur einen Spieler und Büsche. Da der Spieler bereits einen aktiven Kollisionstyp hat und Büsche nicht miteinander kollidieren können, können wir den Typ auf passiv setzen.


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(30, 30), size: Vector2(150, 54), collisionType: CollisionType.passive, ), ); } }


Es ist cool, aber ich kann nicht sehen, ob die Position der Hitbox richtig eingestellt wurde. Wie kann ich das testen?


Nun, Sie können das DebugMode-Feld von Player und Bush auf „true“ setzen. So können Sie sehen, wie Ihre Hitboxen positioniert sind. Lila definiert die Größe der Komponente und Gelb zeigt die Hitbox an.


Jetzt wollen wir erkennen, wann es zu einer Kollision zwischen dem Spieler und dem Busch kommt. Dazu müssen Sie dem Spiel das Mixin HasCollisionDetection und dann CollisionCallbacks für Komponenten hinzufügen, die Kollisionen behandeln müssen.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... }


Pausieren Sie das Spiel vorerst einfach, wenn die Kollision erkannt wird.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.paused = true; } }


Springe oder stirb

Wenn Jack diesen Büschen ausweichen will, muss er springen. Bringen wir es ihm bei. Für diese Funktion benötigen wir die Schwerkraftkonstante und die anfängliche vertikale Geschwindigkeit von Jacks Sprung. Diese Werte wurden nach Augenmaß ausgewählt, Sie können sie also gerne anpassen.


Wie funktioniert also die Schwerkraft? Im Grunde handelt es sich um dieselbe Beschleunigung, nur dass sie auf den Boden gerichtet ist. Daher können wir für die vertikale Position und die Geschwindigkeit dieselben Formeln verwenden. Unser Sprung besteht also aus drei Schritten:

  1. Der Sprung wird ausgelöst und Jacks vertikale Geschwindigkeit ändert sich von Null auf den Anfangswert.
  2. Er bewegt sich nach oben und die Schwerkraft verändert allmählich seine Geschwindigkeit. Irgendwann hört Jack auf, sich nach oben zu bewegen und beginnt, sich nach unten zu bewegen.
  3. Wenn Jack den Boden berührt, müssen wir die Schwerkrafteinwirkung auf ihn beenden und ihn wieder auf Laufen zurücksetzen.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { static const gravity = 1400.0; static const initialJumpVelocity = -700.0; double jumpSpeed = 0; // Earlier written code here... void jump() { if (current != PlayerState.jumping) { current = PlayerState.jumping; jumpSpeed = initialJumpVelocity - (game.currentSpeed / 500); } } void reset() { y = groundYPos; jumpSpeed = 0; current = PlayerState.running; } @override void update(double dt) { super.update(dt); if (current == PlayerState.jumping) { y += jumpSpeed * dt; jumpSpeed += gravity * dt; if (y > groundYPos) { reset(); } } else { y = groundYPos; } } }


Und jetzt lösen wir das Springen per Klick von ForestRunGame aus aus


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { onAction(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { onAction(); } void onAction() { switch (state) { case GameState.intro: case GameState.gameOver: start(); break; case GameState.playing: player.jump(); break; } } }


Jetzt kann Jack mit Büschen umgehen.

Spiel vorbei

Wenn das Spiel vorbei ist, möchten wir Text auf dem Bildschirm anzeigen. Text funktioniert in Flame anders als in Flutter. Sie müssen zuerst eine Schriftart erstellen. Im Grunde ist es nur eine Karte, bei der char ein Schlüssel und sprite ein Wert ist. Fast immer ist die Schriftart des Spiels ein Bild, in dem alle benötigten Symbole gesammelt sind.


Für dieses Spiel brauchen wir nur Ziffern und Großbuchstaben. Also erstellen wir unsere Schriftart. Dazu müssen Sie Quellbild und Glyphen übergeben. Was ist eine Glyphe? Eine Glyphe ist eine Kombination aus Informationen über ein Zeichen, seine Größe und Position im Quellbild.


 class StoneText extends TextBoxComponent { static const digits = '123456789'; static const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; StoneText({ required Image source, required super.position, super.text = '', }) : super( textRenderer: SpriteFontRenderer.fromFont( SpriteFont( source: source, size: 32, ascent: 32, glyphs: [ _buildGlyph(char: '0', left: 480, top: 0), for (var i = 0; i < digits.length; i++) _buildGlyph(char: digits[i], left: 32.0 * i, top: 32), for (var i = 0; i < letters.length; i++) _buildGlyph( char: letters[i], left: 32.0 * (i % 16), top: 64.0 + 32 * (i ~/ 16), ), ], ), letterSpacing: 2, ), ); static Glyph _buildGlyph({ required String char, required double left, required double top, }) => Glyph(char, left: left, top: top, height: 32, width: 32); }


Jetzt können wir das Game-Over-Panel erstellen und im Spiel verwenden.


 class GameOverPanel extends PositionComponent with HasGameReference<ForestRunGame> { bool visible = false; @override Future<void> onLoad() async { final source = game.images.fromCache('font/keypound.png'); add(StoneText(text: 'GAME', source: source, position: Vector2(-144, -16))); add(StoneText(text: 'OVER', source: source, position: Vector2(16, -16))); } @override void renderTree(Canvas canvas) { if (visible) { super.renderTree(canvas); } } @override void onGameResize(Vector2 size) { super.onGameResize(size); x = size.x / 2; y = size.y / 2; } }


Jetzt können wir unser Panel anzeigen, wenn Jack den Busch trifft. Lassen Sie uns auch die Start()-Methode ändern, damit wir das Spiel per Klick neu starten können. Außerdem müssen wir alle Büsche aus dem Wald entfernen.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... late final gameOverPanel = GameOverPanel(); @override Future<void> onLoad() async { // Earlier written code here... add(gameOverPanel); } void gameOver() { paused = true; gameOverPanel.visible = true; state = GameState.gameOver; currentSpeed = 0; } void start() { paused = false; state = GameState.playing; currentSpeed = startSpeed; traveledDistance = 0; player.reset(); foreground.nature.removeAll(foreground.nature.children); gameOverPanel.visible = false; } }


Und jetzt müssen wir den Kollisions-Callback im Player aktualisieren.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.gameOver(); } }


Jetzt können Sie „Game Over“ sehen, wenn Jack gegen einen Busch fährt. Und Sie können das Spiel durch erneutes Klicken neu starten.


Was ist mit meinem Score?

Und die endgültige Berechnung des Touch-Scores.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { late final StoneText scoreText; late final StoneText highText; late final StoneText highScoreText; int _score = 0; int _highScore = 0; // Earlier written code here... @override Future<void> onLoad() async { // Earlier written code here... final font = images.fromCache('font/keypound.png'); scoreText = StoneText(source: font, position: Vector2(20, 20)); highText = StoneText(text: 'HI', source: font, position: Vector2(256, 20)); highScoreText = StoneText( text: '00000', source: font, position: Vector2(332, 20), ); add(scoreText); add(highScoreText); add(highText); setScore(0); } void start() { // Earlier written code here... if (_score > _highScore) { _highScore = _score; highScoreText.text = _highScore.toString().padLeft(5, '0'); } _score = 0; } @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; setScore(traveledDistance ~/ 50); if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } void setScore(int score) { _score = score; scoreText.text = _score.toString().padLeft(5, '0'); } }


Das war's Leute!


Probieren Sie es jetzt aus und versuchen Sie, meinen Highscore zu schlagen. Er liegt bei 2537 Punkten!

Schlussfolgerungen

Es war viel, aber wir haben es geschafft. Wir haben ein minimal funktionsfähiges Produkt für ein Handyspiel mit Physik, Animationen, Punkteberechnung und vielem mehr geschaffen. Es gibt immer Raum für Verbesserungen, und wie bei jedem anderen MVP können wir von unserem Produkt in Zukunft neue Funktionen, Mechaniken und Spielmodi erwarten.


Außerdem gibt es ein flame_audio-Paket, mit dem Sie Hintergrundmusik, Sprung- oder Schlaggeräusche usw. hinzufügen können.


Unser Hauptziel bestand derzeit darin, die grundlegenden Produktfunktionen kurzfristig und mit begrenzter Ressourcenzuweisung zu erstellen. Die Kombination aus Flutter und Flame erwies sich als perfekt geeignet, um ein MVP-Spiel zu erstellen, mit dem Benutzerfeedback gesammelt und die App in Zukunft weiter verbessert werden kann.


Die Ergebnisse unserer Bemühungen können Sie hier überprüfen .


Mit ihren leistungsstarken Funktionen, ihrer Benutzerfreundlichkeit und ihrer florierenden Community sind Flutter und Flame eine überzeugende Wahl für aufstrebende Spieleentwickler. Egal, ob Sie ein erfahrener Profi oder Anfänger sind, diese Kombination bietet die Tools und das Potenzial, um Ihre Spielideen zum Leben zu erwecken. Lassen Sie Ihrer Kreativität freien Lauf, tauchen Sie in die Welt von Flutter und Flame ein und beginnen Sie mit der Entwicklung der nächsten mobilen Gaming-Sensation!


Wir hoffen, Sie fanden diesen Artikel unterhaltsam und informativ. Wenn Sie weitere Einblicke in die Softwareentwicklung wünschen oder Ihr eigenes MVP-Projekt besprechen möchten, zögern Sie nicht, Leobit zu erkunden oder sich an unser technisches Team zu wenden!