Funktionaler Programmieransatz für ein vereinfachtes Spiel mit Scala und LWJGL

11

Ich, ein Java-Imperativ-Programmierer, möchte verstehen, wie eine einfache Version von Space Invaders basierend auf den Entwurfsprinzipien der funktionalen Programmierung (insbesondere der referenziellen Transparenz) generiert wird. Jedes Mal, wenn ich versuche, an ein Design zu denken, verliere ich mich jedoch im Morast extremer Veränderlichkeit, der gleichen Veränderlichkeit, die von Puristen der funktionalen Programmierung gemieden wird.

Um die funktionale Programmierung zu erlernen, habe ich mich entschlossen, ein sehr einfaches interaktives 2D-Spiel, Space Invader (beachten Sie den fehlenden Plural), in Scala mithilfe der LWJGL zu erstellen . Hier sind die Anforderungen für das Grundspiel:

  1. Das Benutzerschiff am unteren Bildschirmrand wurde mit den Tasten "A" und "D" nach links und rechts bewegt

  2. Die von der Leertaste aktiv abgefeuerte Kugel des Benutzerschiffs wird mit einer minimalen Pause zwischen den Schüssen von 0,5 Sekunden aktiviert

  3. Die Kugel eines außerirdischen Schiffes wurde direkt nach unten abgefeuert und durch eine zufällige Zeit von 0,5 bis 1,5 Sekunden zwischen den Schüssen aktiviert

Dinge, die absichtlich aus dem ursprünglichen Spiel weggelassen wurden, sind WxH-Aliens, abbaubare Verteidigungsbarrieren x3 und ein Hochgeschwindigkeits-Untertassenschiff am oberen Bildschirmrand.

Okay, jetzt zur eigentlichen Problemdomäne. Für mich sind alle deterministischen Teile offensichtlich. Es sind die nicht deterministischen Teile, die meine Fähigkeit zu blockieren scheinen, zu überlegen, wie ich mich nähern soll. Die deterministischen Teile sind die Flugbahn der Kugel, sobald sie existiert, die kontinuierliche Bewegung des Außerirdischen und die Explosion aufgrund eines Treffers auf einem (oder beiden) Schiff des Spielers oder dem Außerirdischen. Die nicht deterministischen Teile (für mich) behandeln den Strom von Benutzereingaben, das Abrufen eines zufälligen Werts zum Bestimmen von Alien-Geschossen und das Behandeln der Ausgabe (sowohl Grafik als auch Ton).

Ich kann (und habe) im Laufe der Jahre viele dieser Arten von Spieleentwicklungen durchführen. Alles war jedoch vom imperativen Paradigma. Und LWJGL bietet sogar eine sehr einfache Java-Version von Space Invaders (von denen ich mit Scala als Java ohne Semikolons zu Scala gewechselt bin).

Hier sind einige Links, die sich mit diesem Bereich befassen, von denen keiner direkt mit den Ideen so umgegangen zu sein scheint, wie es eine Person aus der Java / Imperative-Programmierung verstehen würde:

  1. Rein funktionale Retrogames, Teil 1 von James Hague

  2. Ähnliche Stapelüberlaufpfosten

  3. Clojure / Lisp-Spiele

  4. Haskell-Spiele auf Stapelüberlauf

  5. Yampas (in Haskell) funktionale reaktive Programmierung

Es scheint, dass es einige Ideen in den Clojure / Lisp- und Haskell-Spielen gibt (mit Quelle). Leider bin ich nicht in der Lage, den Code in mentale Modelle zu lesen / zu interpretieren, die für mein vereinfachtes Java-Imperativhirn keinen Sinn ergeben.

Ich bin so begeistert von den Möglichkeiten, die FP bietet, dass ich nur die Multithread-Skalierbarkeitsfunktionen probieren kann. Ich habe das Gefühl, ich könnte herausfinden, wie etwas so Einfaches wie das Zeit + Ereignis + Zufallsmodell für Space Invader implementiert werden kann, indem die deterministischen und nicht deterministischen Teile in einem richtig entworfenen System getrennt werden, ohne dass es sich in eine Art fortgeschrittene mathematische Theorie verwandelt ;; dh Yampa, ich wäre bereit. Wenn das Erlernen des theoretischen Niveaus, das Yampa zu benötigen scheint, um einfache Spiele erfolgreich zu generieren, erforderlich ist, überwiegt der Aufwand für den Erwerb aller erforderlichen Schulungen und konzeptionellen Rahmenbedingungen mein Verständnis der Vorteile von FP bei weitem (zumindest für dieses stark vereinfachte Lernexperiment) ).

Alle Rückmeldungen, vorgeschlagenen Modelle und vorgeschlagenen Methoden zur Annäherung an den Problembereich (spezifischer als die von James Hague behandelten allgemeinen Aspekte) wären sehr willkommen.

chaotisches Gleichgewicht
quelle
1
Ich habe den Teil über Ihr Blog aus der Frage entfernt, da er für die Frage selbst nicht wesentlich war. Fühlen Sie sich frei, einen Link zu einem Folgeartikel hinzuzufügen, wenn Sie zum Schreiben kommen.
Yannis
@Yannis - Verstanden. Tyvm!
chaotic3quilibrium
Sie haben nach Scala gefragt, weshalb dies nur ein Kommentar ist. Caves of Clojure ist imho eine überschaubare Lektüre darüber, wie man einen schurkenhaften FP-Stil implementiert. Es behandelt den Status, indem es einen Schnappschuss der Welt zurückgibt, den der Autor dann testen kann. Das ist ziemlich toll. Vielleicht können Sie durch die Beiträge stöbern und sehen, ob Teile seiner Implementierung leicht auf Scala
IAE

Antworten:

5

Eine idiomatische Scala / LWJGL-Implementierung von Space Invaders würde nicht so sehr wie eine Haskell / OpenGL-Implementierung aussehen. Das Schreiben einer Haskell-Implementierung könnte meiner Meinung nach eine bessere Übung sein. Wenn Sie jedoch bei Scala bleiben möchten, finden Sie hier einige Ideen, wie Sie es in funktionalem Stil schreiben können.

Versuchen Sie, nur unveränderliche Objekte zu verwenden. Sie könnten ein GameObjekt haben, das a Player, a enthält Set[Invader](verwenden Sie unbedingt immutable.Set) usw. Geben Sie Playerein update(state: Game): Player(es könnte auch dauern depressedKeys: Set[Int]usw.) und geben Sie den anderen Klassen ähnliche Methoden.

Zufällig scala.util.Randomist nicht unveränderlich wie bei Haskell System.Random, aber Sie könnten Ihren eigenen unveränderlichen Generator bauen. Dieser ist ineffizient, zeigt aber die Idee.

case class ImmutablePRNG(val seed: Long) extends Immutable {
    lazy val nextLong: (Long, ImmutableRNG) =
        (seed, ImmutablePRNG(new Random(seed).nextLong()))
    ...
}

Für die Eingabe und das Rendern von Tastatur / Maus führt kein Weg daran vorbei, unreine Funktionen aufzurufen. Sie sind auch in Haskell unrein, sie sind nur in IOusw. eingekapselt, so dass Ihre tatsächlichen Funktionsobjekte technisch rein sind (sie lesen oder schreiben selbst keinen Status, sie beschreiben Routinen, die dies tun, und das Laufzeitsystem führt diese Routinen aus). .

Nur nicht setzen I / O - Code in Ihren unveränderlichen Objekten wie Game, Playerund Invader. Sie können Playereine renderMethode angeben, aber sie sollte so aussehen

render(state: Game, buffer: Image): Image

Leider passt dies nicht gut zu LWJGL, da es so zustandsbasiert ist, aber Sie können Ihre eigenen Abstraktionen darauf aufbauen. Sie könnten eine ImmutableCanvasKlasse haben, die eine AWT enthält Canvas, und ihre blit(und andere Methoden) könnten den Basiswert klonen Canvas, an ihn übergeben Display.setParent, dann das Rendern durchführen und die neue zurückgeben Canvas(in Ihrem unveränderlichen Wrapper).


Update : Hier ist ein Java-Code, der zeigt, wie ich das machen würde. (Ich hätte in Scala fast den gleichen Code geschrieben, außer dass ein unveränderlicher Satz eingebaut ist und einige für jede Schleife durch Karten oder Falten ersetzt werden könnten.) Ich habe einen Spieler gemacht, der sich bewegt und Kugeln abfeuert, aber ich fügte keine Feinde hinzu, da der Code bereits lang wurde. Ich habe fast alles kopiert - ich denke, das ist das wichtigste Konzept.

import java.awt.*;
import java.awt.geom.*;
import java.awt.image.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.*;

import static java.awt.event.KeyEvent.*;

// An immutable wrapper around a Set. Doesn't implement Set or Collection
// because that would require quite a bit of code.
class ImmutableSet<T> implements Iterable<T> {
  final Set<T> backingSet;

  // Construct an empty set.
  ImmutableSet() {
    backingSet = new HashSet<T>();
  }

  // Copy constructor.
  ImmutableSet(ImmutableSet<T> src) {
    backingSet = new HashSet<T>(src.backingSet);
  }

  // Return a new set with an element added.
  ImmutableSet<T> plus(T elem) {
    ImmutableSet<T> copy = new ImmutableSet<T>(this);
    copy.backingSet.add(elem);
    return copy;
  }

  // Return a new set with an element removed.
  ImmutableSet<T> minus(T elem) {
    ImmutableSet<T> copy = new ImmutableSet<T>(this);
    copy.backingSet.remove(elem);
    return copy;
  }

  boolean contains(T elem) {
    return backingSet.contains(elem);
  }

  @Override public Iterator<T> iterator() {
    return backingSet.iterator();
  }
}

// An immutable, copy-on-write wrapper around BufferedImage.
class ImmutableImage {
  final BufferedImage backingImage;

  // Construct a blank image.
  ImmutableImage(int w, int h) {
    backingImage = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
  }

  // Copy constructor.
  ImmutableImage(ImmutableImage src) {
    backingImage = new BufferedImage(
        src.backingImage.getColorModel(),
        src.backingImage.copyData(null),
        false, null);
  }

  // Clear the image.
  ImmutableImage clear(Color c) {
    ImmutableImage copy = new ImmutableImage(this);
    Graphics g = copy.backingImage.getGraphics();
    g.setColor(c);
    g.fillRect(0, 0, backingImage.getWidth(), backingImage.getHeight());
    return copy;
  }

  // Draw a filled circle.
  ImmutableImage fillCircle(int x, int y, int r, Color c) {
    ImmutableImage copy = new ImmutableImage(this);
    Graphics g = copy.backingImage.getGraphics();
    g.setColor(c);
    g.fillOval(x - r, y - r, r * 2, r * 2);
    return copy;
  }
}

// An immutable, copy-on-write object describing the player.
class Player {
  final int x, y;
  final int ticksUntilFire;

  Player(int x, int y, int ticksUntilFire) {
    this.x = x;
    this.y = y;
    this.ticksUntilFire = ticksUntilFire;
  }

  // Construct a player at the starting position, ready to fire.
  Player() {
    this(SpaceInvaders.W / 2, SpaceInvaders.H - 50, 0);
  }

  // Update the game state (repeatedly called for each game tick).
  GameState update(GameState currentState) {
    // Update the player's position based on which keys are down.
    int newX = x;
    if (currentState.keyboard.isDown(VK_LEFT) || currentState.keyboard.isDown(VK_A))
      newX -= 2;
    if (currentState.keyboard.isDown(VK_RIGHT) || currentState.keyboard.isDown(VK_D))
      newX += 2;

    // Update the time until the player can fire.
    int newTicksUntilFire = ticksUntilFire;
    if (newTicksUntilFire > 0)
      --newTicksUntilFire;

    // Replace the old player with an updated player.
    Player newPlayer = new Player(newX, y, newTicksUntilFire);
    return currentState.setPlayer(newPlayer);
  }

  // Update the game state in response to a key press.
  GameState keyPressed(GameState currentState, int key) {
    if (key == VK_SPACE && ticksUntilFire == 0) {
      // Fire a bullet.
      Bullet b = new Bullet(x, y);
      ImmutableSet<Bullet> newBullets = currentState.bullets.plus(b);
      currentState = currentState.setBullets(newBullets);

      // Make the player wait 25 ticks before firing again.
      currentState = currentState.setPlayer(new Player(x, y, 25));
    }
    return currentState;
  }

  ImmutableImage render(ImmutableImage img) {
    return img.fillCircle(x, y, 20, Color.RED);
  }
}

// An immutable, copy-on-write object describing a bullet.
class Bullet {
  final int x, y;
  static final int radius = 5;

  Bullet(int x, int y) {
    this.x = x;
    this.y = y;
  }

  // Update the game state (repeatedly called for each game tick).
  GameState update(GameState currentState) {
    ImmutableSet<Bullet> bullets = currentState.bullets;
    bullets = bullets.minus(this);
    if (y + radius >= 0)
      // Add a copy of the bullet which has moved up the screen slightly.
      bullets = bullets.plus(new Bullet(x, y - 5));
    return currentState.setBullets(bullets);
  }

  ImmutableImage render(ImmutableImage img) {
    return img.fillCircle(x, y, radius, Color.BLACK);
  }
}

// An immutable, copy-on-write snapshot of the keyboard state at some time.
class KeyboardState {
  final ImmutableSet<Integer> depressedKeys;

  KeyboardState(ImmutableSet<Integer> depressedKeys) {
    this.depressedKeys = depressedKeys;
  }

  KeyboardState() {
    this(new ImmutableSet<Integer>());
  }

  GameState keyPressed(GameState currentState, int key) {
    return currentState.setKeyboard(new KeyboardState(depressedKeys.plus(key)));
  }

  GameState keyReleased(GameState currentState, int key) {
    return currentState.setKeyboard(new KeyboardState(depressedKeys.minus(key)));
  }

  boolean isDown(int key) {
    return depressedKeys.contains(key);
  }
}

// An immutable, copy-on-write description of the entire game state.
class GameState {
  final Player player;
  final ImmutableSet<Bullet> bullets;
  final KeyboardState keyboard;

  GameState(Player player, ImmutableSet<Bullet> bullets, KeyboardState keyboard) {
    this.player = player;
    this.bullets = bullets;
    this.keyboard = keyboard;
  }

  GameState() {
    this(new Player(), new ImmutableSet<Bullet>(), new KeyboardState());
  }

  GameState setPlayer(Player newPlayer) {
    return new GameState(newPlayer, bullets, keyboard);
  }

  GameState setBullets(ImmutableSet<Bullet> newBullets) {
    return new GameState(player, newBullets, keyboard);
  }

  GameState setKeyboard(KeyboardState newKeyboard) {
    return new GameState(player, bullets, newKeyboard);
  }

  // Update the game state (repeatedly called for each game tick).
  GameState update() {
    GameState current = this;
    current = current.player.update(current);
    for (Bullet b : current.bullets)
      current = b.update(current);
    return current;
  }

  // Update the game state in response to a key press.
  GameState keyPressed(int key) {
    GameState current = this;
    current = keyboard.keyPressed(current, key);
    current = player.keyPressed(current, key);
    return current;
  }

  // Update the game state in response to a key release.
  GameState keyReleased(int key) {
    GameState current = this;
    current = keyboard.keyReleased(current, key);
    return current;
  }

  ImmutableImage render() {
    ImmutableImage img = new ImmutableImage(SpaceInvaders.W, SpaceInvaders.H);
    img = img.clear(Color.BLUE);
    img = player.render(img);
    for (Bullet b : bullets)
      img = b.render(img);
    return img;
  }
}

public class SpaceInvaders {
  static final int W = 640, H = 480;

  static GameState currentState = new GameState();

  public static void main(String[] _) {
    JFrame frame = new JFrame() {{
      setSize(W, H);
      setTitle("Space Invaders");
      setContentPane(new JPanel() {
        @Override public void paintComponent(Graphics g) {
          BufferedImage img = SpaceInvaders.currentState.render().backingImage;
          ((Graphics2D) g).drawRenderedImage(img, new AffineTransform());
        }
      });
      addKeyListener(new KeyAdapter() {
        @Override public void keyPressed(KeyEvent e) {
          currentState = currentState.keyPressed(e.getKeyCode());
        }
        @Override public void keyReleased(KeyEvent e) {
          currentState = currentState.keyReleased(e.getKeyCode());
        }
      });
      setLocationByPlatform(true);
      setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      setVisible(true);
    }};

    for (;;) {
      currentState = currentState.update();
      frame.repaint();
      try {
        Thread.sleep(20);
      } catch (InterruptedException e) {}
    }
  }
}
Daniel Lubarov
quelle
2
Ich habe Java-Code hinzugefügt - hilft das? Wenn der Code seltsam aussieht, würde ich mir einige kleinere Beispiele für unveränderliche Copy-on-Write-Klassen ansehen. Das sieht nach einer anständigen Erklärung aus.
Daniel Lubarov
2
@ chaotic3quilibrium ist nur eine normale Kennung. Ich benutze es manchmal anstatt argswenn der Code Argumente ignoriert. Entschuldigung für die unnötige Verwirrung.
Daniel Lubarov
2
Keine Bange. Ich habe das einfach angenommen und bin weitergegangen. Ich habe gestern eine Weile mit Ihrem Beispielcode gespielt. Ich glaube, ich habe den Dreh raus. Jetzt frage ich mich, ob mir noch etwas fehlt. Die Anzahl der temporären Objekte ist enorm. Jeder Tick erzeugt einen Frame, der einen GameState anzeigt. Um vom GameState des vorherigen Ticks zu diesem GameState zu gelangen, müssen mehrere dazwischenliegende GameState-Instanzen generiert werden, von denen jede eine kleine Anpassung gegenüber dem vorherigen GameState aufweist.
chaotic3quilibrium
3
Ja, es ist ziemlich verschwenderisch. Ich denke nicht, dass die GameStateKopien so teuer wären, obwohl mehrere pro Tick gemacht werden, da sie jeweils ~ 32 Bytes sind. Das Kopieren des ImmutableSets kann jedoch teuer sein, wenn viele Kugeln gleichzeitig aktiv sind. Wir könnten durch ImmutableSeteine Baumstruktur ersetzen scala.collection.immutable.TreeSet, um das Problem zu verringern.
Daniel Lubarov
2
Und ImmutableImageist noch schlimmer, da es ein großes Raster kopiert, wenn es geändert wird. Es gibt einige Dinge, die wir tun könnten, um dieses Problem zu verringern, aber ich denke, es wäre am praktischsten, nur Rendering-Code im imperativen Stil zu schreiben (selbst Haskell-Programmierer tun dies normalerweise).
Daniel Lubarov
4

Nun, Sie behindern Ihre Bemühungen durch die Verwendung von LWJGL - nichts dagegen, aber es wird nicht funktionierende Redewendungen auferlegen.

Ihre Forschung entspricht jedoch dem, was ich empfehlen würde. "Ereignisse" werden in der funktionalen Programmierung durch Konzepte wie funktionale reaktive Programmierung oder Datenflussprogrammierung gut unterstützt. Sie können Reactive , eine FRP-Bibliothek für Scala, ausprobieren, um festzustellen , ob sie Ihre Nebenwirkungen enthalten kann.

Nehmen Sie auch eine Seite aus Haskell heraus: Verwenden Sie Monaden, um Nebenwirkungen zu kapseln / zu isolieren. Siehe Staats- und E / A-Monaden.

Daniel C. Sobral
quelle
Tyvm für Ihre Antwort. Ich bin nicht sicher, wie ich die Tastatur- / Mauseingabe und die Grafik- / Tonausgabe von Reactive erhalten soll. Ist es da und ich vermisse es einfach? In Bezug auf Ihren Hinweis auf die Verwendung einer Monade - ich lerne gerade etwas über sie und verstehe immer noch nicht ganz, was eine Monade ist.
chaotic3quilibrium
3

Die nicht deterministischen Teile (für mich) behandeln den Strom von Benutzereingaben ... die Ausgabe (sowohl Grafik als auch Ton).

Ja, IO ist nicht deterministisch und "alles über" Nebenwirkungen. In einer nicht reinen Funktionssprache wie Scala ist das kein Problem.

Umgang mit dem Abrufen eines zufälligen Wertes zur Bestimmung von außerirdischen Geschossen

Sie können die Ausgabe eines Pseudozufallszahlengenerators als unendliche Folge ( Seqin Scala) behandeln.

...

Wo sehen Sie insbesondere die Notwendigkeit der Veränderlichkeit? Wenn ich damit rechnen darf, könnten Sie sich vorstellen, dass Ihre Sprites eine Position im Raum haben, die sich im Laufe der Zeit ändert. Es kann nützlich sein, in einem solchen Kontext über "Reißverschlüsse" nachzudenken: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php

Larry OBrien
quelle
Ich weiß nicht einmal, wie ich den Anfangscode so strukturieren soll, dass es sich um eine idiomatische funktionale Programmierung handelt. Danach verstehe ich nicht die richtige (oder bevorzugte) Technik zum Hinzufügen des "unreinen" Codes. Mir ist bewusst, dass ich Scala als "Java ohne Semikolons" verwenden kann. Das will ich nicht machen. Ich möchte lernen, wie FP eine sehr einfache dynamische Umgebung adressiert, ohne sich auf Zeit- oder Wertveränderlichkeitslecks zu verlassen. Macht das Sinn?
chaotic3quilibrium