Approche de programmation fonctionnelle pour un jeu simplifié utilisant Scala et LWJGL

11

Moi, un programmeur impératif Java, je voudrais comprendre comment générer une version simple de Space Invaders basée sur les principes de conception de la programmation fonctionnelle (en particulier la transparence référentielle). Cependant, chaque fois que j'essaie de penser à un design, je me perds dans le bourbier de la mutabilité extrême, la même mutabilité qui est rejetée par les puristes de la programmation fonctionnelle.

Pour tenter d'apprendre la programmation fonctionnelle, j'ai décidé d'essayer de créer un jeu interactif 2D très simple, Space Invader (notez le manque de pluriel), à Scala en utilisant le LWJGL . Voici les conditions requises pour le jeu de base:

  1. Navire utilisateur en bas de l'écran déplacé vers la gauche et la droite par les touches "A" et "D" respectivement

  2. Balle de navire de l'utilisateur tirée directement activée par la barre d'espace avec une pause minimale entre les tirs de 0,5 seconde

  3. Balle de vaisseau extraterrestre tirée directement activée par un temps aléatoire de 0,5 à 1,5 seconde entre les tirs

Les éléments intentionnellement exclus du jeu original sont les extraterrestres WxH, les barrières de défense dégradables x3, le vaisseau soucoupe à grande vitesse en haut de l'écran.

Bon, passons maintenant au domaine de problème réel. Pour moi, toutes les parties déterministes sont évidentes. Ce sont les parties non déterministes qui semblent bloquer ma capacité à réfléchir à l'approche. Les parties déterministes sont la trajectoire de la balle une fois qu'elles existent, le mouvement continu de l'étranger et l'explosion due à un coup sur l'un (ou les deux) du navire du joueur ou de l'étranger. Les parties non déterministes (pour moi) gèrent le flux d'entrée utilisateur, gèrent l'extraction d'une valeur aléatoire pour déterminer les tirs de balles extraterrestres et gèrent la sortie (graphique et sonore).

Je peux faire (et j'ai fait) beaucoup de ce type de développement de jeux au fil des ans. Cependant, tout cela provenait du paradigme impératif. Et LWJGL fournit même une version Java très simple des envahisseurs spatiaux (dont j'ai commencé à passer à Scala en utilisant Scala comme Java sans point-virgule).

Voici quelques liens qui parlent de ce domaine dont aucun ne semble avoir traité directement les idées d'une manière qu'une personne issue de la programmation Java / impérative comprendrait:

  1. Rétrogames purement fonctionnels, partie 1 par James Hague

  2. Poteau de débordement de pile similaire

  3. Jeux de Clojure / Lisp

  4. Jeux Haskell sur Stack Overflow

  5. Programmation réactive fonctionnelle de Yampa (à Haskell)

Il semble qu'il y ait quelques idées dans les jeux Clojure / Lisp et Haskell (avec source). Malheureusement, je ne suis pas en mesure de lire / interpréter le code en modèles mentaux qui ont un sens pour mon cerveau impératif Java simple d'esprit.

Je suis tellement excité par les possibilités offertes par FP, je peux juste goûter aux capacités d'évolutivité multithread. Je me sens comme si j'étais capable de comprendre comment quelque chose d'aussi simple que le modèle temps + événement + aléatoire pour Space Invader peut être implémenté, séparant les parties déterministes et non déterministes dans un système correctement conçu sans qu'il se transforme en ce qui ressemble à une théorie mathématique avancée ; c'est à dire Yampa, je serais prêt. Si l'apprentissage du niveau de théorie que Yampa semble exiger pour réussir à générer des jeux simples est nécessaire, la surcharge d'acquisition de toute la formation et du cadre conceptuel nécessaires dépassera largement ma compréhension des avantages de la PF (au moins pour cette expérience d'apprentissage trop simplifiée). ).

Tout retour d'information, modèles proposés, méthodes suggérées pour aborder le domaine du problème (plus spécifiques que les généralités couvertes par James Hague) seraient grandement appréciés.

chaotic3quilibrium
la source
1
J'ai supprimé la partie concernant votre blog de la question, car elle n'était pas essentielle à la question elle-même. N'hésitez pas à inclure un lien vers un article de suivi lorsque vous arriverez à l'écrire.
yannis
@Yannis - J'ai compris. Tyvm!
chaotic3quilibrium
Vous avez demandé Scala, c'est pourquoi ce n'est qu'un commentaire. Caves of Clojure est à mon humble avis une lecture gérable sur la façon de mettre en œuvre un style FP roguelike. Il gère l'état en renvoyant un instantané du monde que l'auteur peut ensuite tester. C'est plutôt cool. Peut-être que vous pouvez parcourir les messages et voir si des parties de son implémentation sont facilement transférables à Scala
IAE

Réponses:

5

Une implémentation idiomatique Scala / LWJGL de Space Invaders ne ressemblerait pas autant à une implémentation Haskell / OpenGL. Écrire une implémentation Haskell pourrait être un meilleur exercice à mon avis. Mais si vous voulez vous en tenir à Scala, voici quelques idées pour l'écrire dans un style fonctionnel.

Essayez d'utiliser uniquement des objets immuables. Vous pourriez avoir un Gameobjet qui contient un Player, un Set[Invader](assurez-vous de l'utiliser immutable.Set), etc. Donnez Playerun update(state: Game): Player(cela pourrait aussi prendre depressedKeys: Set[Int], etc.) et donnez aux autres classes des méthodes similaires.

Pour le hasard, scala.util.Randomn'est pas immuable comme Haskell System.Random, mais vous pouvez créer votre propre générateur immuable. Celui-ci est inefficace mais il démontre l'idée.

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

Pour la saisie et le rendu au clavier / souris, il n'y a aucun moyen d'appeler des fonctions impures. Ils sont également impurs dans Haskell, ils sont simplement encapsulés dans IOetc. afin que vos objets de fonction réels soient techniquement purs (ils ne lisent ni n'écrivent eux-mêmes, ils décrivent les routines qui le font, et le système d'exécution exécute ces routines) .

Ne mettez simplement pas de code d'E / S dans vos objets immuables comme Game, Playeret Invader. Vous pouvez donner Playerune renderméthode, mais elle devrait ressembler à

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

Malheureusement, cela ne correspond pas bien à LWJGL car il est basé sur l'état, mais vous pouvez créer vos propres abstractions par-dessus. Vous pourriez avoir une ImmutableCanvasclasse qui contient un AWT Canvas, et ses blit(et d'autres méthodes) pourraient cloner le sous-jacent Canvas, le passer à Display.setParent, puis effectuer le rendu et renvoyer le nouveau Canvas(dans votre wrapper immuable).


Mise à jour : voici du code Java montrant comment j'y arriverais. (J'aurais écrit presque le même code dans Scala, sauf qu'un ensemble immuable est intégré et que quelques boucles pour chaque peuvent être remplacées par des cartes ou des plis.) J'ai fait un joueur qui se déplace et tire des balles, mais je n'a pas ajouté d'ennemis puisque le code devenait déjà long. J'ai fait à peu près tout ce qui est copie sur écriture - je pense que c'est le concept le plus important.

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
la source
2
J'ai ajouté du code Java - cela aide-t-il? Si le code semble étrange, je regarderais quelques exemples plus petits de classes immuables de copie sur écriture. Cela ressemble à une explication décente.
Daniel Lubarov
2
@ chaotic3quilibrium c'est juste un identifiant normal. Je l'utilise parfois au lieu de argssi le code ignore les arguments. Désolé pour la confusion inutile.
Daniel Lubarov
2
Pas de soucis. Je viens de supposer cela et j'ai continué. Hier, j'ai joué avec votre exemple de code. Je pense avoir compris l'idée. Maintenant, je me demande si je manque quelque chose d'autre. Le nombre d'objets temporaires est énorme. Chaque tick génère un cadre qui affiche un GameState. Et pour arriver à ce GameState à partir du GameState du tick précédent, il faut générer un certain nombre d'instances GameState intermédiaires, chacune avec un petit ajustement par rapport au GameState précédent.
chaotic3quilibrium
3
Ouais, c'est plutôt du gaspillage. Je ne pense pas que les GameStatecopies seraient aussi coûteuses, même si plusieurs sont faites à chaque tick, car elles sont ~ 32 octets chacune. Mais la copie des ImmutableSets pourrait coûter cher si de nombreuses balles sont vivantes en même temps. Nous pourrions remplacer ImmutableSetpar une structure arborescente comme scala.collection.immutable.TreeSetpour atténuer le problème.
Daniel Lubarov
2
Et ImmutableImagec'est encore pire, car il copie un grand raster lorsqu'il est modifié. Il y a certaines choses que nous pourrions faire pour atténuer ce problème aussi, mais je pense qu'il serait plus pratique d'écrire simplement du code de rendu dans un style impératif (même les programmeurs Haskell le font normalement).
Daniel Lubarov
4

Eh bien, vous entravez vos efforts en utilisant LWJGL - rien contre, mais cela imposera des idiomes non fonctionnels.

Votre recherche est cependant conforme à ce que je recommanderais. Les «événements» sont bien pris en charge dans la programmation fonctionnelle grâce à des concepts tels que la programmation réactive fonctionnelle ou la programmation de flux de données. Vous pouvez essayer Reactive , une bibliothèque FRP pour Scala, pour voir si elle peut contenir vos effets secondaires.

Prenez également une page de Haskell: utilisez des monades pour encapsuler / isoler les effets secondaires. Voir les monades d'état et d'E / S.

Daniel C. Sobral
la source
Tyvm pour votre réponse. Je ne sais pas comment obtenir l'entrée clavier / souris et la sortie graphique / sonore de Reactive. Est-il là et ça me manque juste? En ce qui concerne votre référence à l'utilisation d'une monade - je viens tout juste d'en apprendre sur eux et je ne comprends toujours pas complètement ce qu'est une monade.
chaotic3quilibrium
3

Les parties non déterministes (pour moi) gèrent le flux d'entrée utilisateur ... gèrent la sortie (graphique et sonore).

Oui, les IO sont des effets secondaires non déterministes et «tout sur». Ce n'est pas un problème dans un langage fonctionnel non pur comme Scala.

gestion de l'extraction d'une valeur aléatoire pour déterminer les tirs de balles extraterrestres

Vous pouvez traiter la sortie d'un générateur de nombres pseudo-aléatoires comme une séquence infinie ( Seqdans Scala).

...

Où, en particulier, voyez-vous le besoin de mutabilité? Si je peux m'y attendre, vous pourriez penser que vos sprites ont une position dans l'espace qui varie avec le temps. Vous pouvez trouver utile de penser aux "fermetures à glissière" dans un tel contexte: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php

Larry OBrien
la source
Je ne sais même pas comment structurer le code initial pour qu'il soit une programmation fonctionnelle idiomatique. Après cela, je ne comprends pas la technique correcte (ou préférée) pour ajouter le code "impur". Je suis conscient que je peux utiliser Scala comme "Java sans point-virgule". Je ne veux pas faire ça. Je veux savoir comment FP aborde un environnement dynamique très simple sans compter sur les fuites de mutabilité temporelles ou de valeur. Est-ce que cela a du sens?
chaotic3quilibrium