Approccio di programmazione funzionale per un gioco semplificato usando Scala e LWJGL


11

Io, programmatore imperativo di Java, vorrei capire come generare una versione semplice di Space Invaders basata sui principi di progettazione della programmazione funzionale (in particolare la trasparenza referenziale). Tuttavia, ogni volta che provo a pensare a un progetto, mi perdo nella massa di estrema mutabilità, la stessa mutabilità che viene evitata dai puristi della programmazione funzionale.

Come tentativo di imparare la programmazione funzionale, ho deciso di provare a creare un gioco interattivo 2D molto semplice, Space Invader (notare la mancanza del plurale), a Scala usando LWJGL . Ecco i requisiti per il gioco di base:

  1. La nave dell'utente nella parte inferiore dello schermo è stata spostata a destra e sinistra rispettivamente dai tasti "A" e "D"

  2. Proiettile di nave dell'utente sparato verso l'alto attivato dalla barra spaziatrice con una pausa minima tra i colpi di 0,5 secondi

  3. Proiettile di nave aliena sparato verso il basso attivato da un tempo casuale di 0,5 a 1,5 secondi tra i colpi

Le cose lasciate intenzionalmente fuori dal gioco originale sono gli alieni WxH, le barriere di difesa degradabili x3, la nave del piattino ad alta velocità nella parte superiore dello schermo.

Bene, ora al dominio del problema reale. Per me, tutte le parti deterministiche sono ovvie. Sono le parti non deterministiche che sembrano bloccare la mia capacità di considerare come approcciare. Le parti deterministiche sono la traiettoria del proiettile una volta che esistono, il movimento continuo dell'alieno e l'esplosione a causa di un colpo su una (o su entrambe) la nave del giocatore o l'alieno. Le parti non deterministiche (per me) stanno gestendo il flusso di input dell'utente, gestendo il recupero di un valore casuale per la determinazione di lanci di proiettili alieni e la gestione dell'output (sia grafico che sonoro).

Posso fare (e aver fatto) molto di questo tipo di sviluppo del gioco nel corso degli anni. Tuttavia, tutto proveniva dal paradigma imperativo. E LWJGL fornisce anche una versione Java molto semplice degli invasori spaziali (di cui ho iniziato a spostarmi su Scala usando Scala come Java-senza punti e virgola).

Ecco alcuni link che parlano di quest'area di cui nessuno sembra aver trattato direttamente le idee in un modo che una persona proveniente dalla programmazione Java / Imperative avrebbe capito:

  1. Retrogames puramente funzionali, parte 1 di James Hague

  2. Post Stack Overflow simile

  3. Clojure / Lisp Games

  4. Haskell Games su Stack Overflow

  5. Programmazione reattiva funzionale di Yampa (in Haskell)

Sembra che ci siano alcune idee nei giochi Clojure / Lisp e Haskell (con fonte). Sfortunatamente, non sono in grado di leggere / interpretare il codice in modelli mentali che hanno un senso per il mio cervello imperativo Java semplice.

Sono così entusiasta delle possibilità offerte da FP, posso solo assaggiare le capacità di scalabilità multi-thread. Mi sento come se fossi in grado di capire come qualcosa di semplice come il modello tempo + evento + casualità per Space Invader possa essere implementato, separando le parti deterministiche e non deterministiche in un sistema ben progettato senza che si trasformi in ciò che sembra una teoria matematica avanzata ; cioè Yampa, sarei pronto. Se l'apprendimento del livello di teoria che Yampa sembra richiedere per generare con successo semplici giochi è necessario, allora il sovraccarico di acquisire tutta la formazione e il quadro concettuale necessari supererà di gran lunga la mia comprensione dei benefici del FP (almeno per questo esperimento di apprendimento troppo semplificato ).

Qualsiasi feedback, modelli proposti, metodi suggeriti per affrontare il problema (più specifici delle generalità coperte da James Hague) sarebbero molto apprezzati.


1
Ho rimosso la parte relativa al tuo blog dalla domanda, perché non era essenziale alla domanda stessa. Sentiti libero di includere un link a un articolo di follow-up quando vieni a scriverlo.
yannis,

@Yannis - Capito. Tyvm!
chaotic3quilibrium,

Hai chiesto Scala, motivo per cui questo è solo un commento. Caves of Clojure è una lettura gestibile su come implementare uno stile FP roguelike. Gestisce lo stato restituendo un'istantanea del mondo che l'autore può quindi testare. È abbastanza bello. Forse puoi sfogliare i post e vedere se alcune parti della sua implementazione sono facilmente trasferibili a Scala
IAE

Risposte:


5

Una idiomatica implementazione Scala / LWJGL di Space Invaders non assomiglierebbe molto a un'implementazione di Haskell / OpenGL. Scrivere un'implementazione di Haskell potrebbe essere un esercizio migliore secondo me. Ma se vuoi restare con Scala, ecco alcune idee su come scriverlo in stile funzionale.

Prova a usare solo oggetti immutabili. Si potrebbe avere un Gameoggetto che detiene una Player, un Set[Invader](assicurarsi di utilizzare immutable.Set), ecc Dare Playerun update(state: Game): Player(si potrebbe anche prendere depressedKeys: Set[Int], ecc), e dare le altre classi metodi simili.

Per casualità, scala.util.Randomnon è immutabile come quello di Haskell System.Random, ma potresti creare il tuo generatore immutabile. Questo è inefficiente ma dimostra l'idea.

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

Per l'input e il rendering da tastiera / mouse, non c'è modo di aggirare le funzioni impure. Sono impuri anche in Haskell, sono solo incapsulati in IOecc. In modo che i tuoi oggetti funzione reali siano tecnicamente puri (non leggono o scrivono lo stato da soli, descrivono routine che lo fanno e il sistema di runtime esegue quelle routine) .

Basta non inserire il codice I / O nei tuoi oggetti immutabili come Game, Playere Invader. Puoi dare Playerun rendermetodo, ma dovrebbe apparire come

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

Sfortunatamente questo non si adatta bene a LWJGL poiché è basato sullo stato, ma su di esso puoi costruire le tue astrazioni. Potresti avere una ImmutableCanvasclasse che contiene un AWT Canvase il suo blit(e altri metodi) potrebbe clonare il sottostante Canvas, passarlo a Display.setParent, quindi eseguire il rendering e restituire il nuovo Canvas(nel tuo involucro immutabile).


Aggiornamento : ecco un codice Java che mostra come farei. (Avrei scritto quasi lo stesso codice in Scala, tranne per il fatto che un set immutabile è incorporato e che alcuni loop per ogni possono essere sostituiti con mappe o pieghe.) Ho creato un giocatore che si muove e lancia proiettili, ma io non ha aggiunto nemici poiché il codice stava già diventando lungo. Ho fatto praticamente tutto il copy-on-write - penso che questo sia il concetto più importante.

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) {}
    }
  }
}

2
Ho aggiunto del codice Java: aiuta? Se il codice sembra strano, guarderei alcuni esempi più piccoli di classi immutabili, copia su scrittura. Questo si presenta come una spiegazione decente.
Daniel Lubarov,

2
@ chaotic3quilibrium è solo un normale identificatore. A volte lo uso invece che argsse il codice ignora gli argomenti. Ci scusiamo per la confusione non necessaria.
Daniel Lubarov,

2
Nessun problema. L'ho solo assunto e sono andato avanti. Ho giocato con il tuo codice di esempio per ieri. Penso di aver capito l'idea. Ora, mi chiedo se mi manca qualcos'altro. Il numero di oggetti temporanei è enorme. Ogni segno di spunta genera un frame che visualizza un GameState. E per arrivare a quel GameState dal GameState del tick precedente è necessario generare una serie di istanze GameState intervenute, ognuna con una piccola modifica rispetto al GameState precedente.
chaotic3quilibrium,

3
Sì, è piuttosto dispendioso. Non penso che le GameStatecopie sarebbero così costose, anche se ne vengono fatte diverse per ogni tick, dato che sono ~ 32 byte ciascuna. Ma copiare gli ImmutableSets potrebbe essere costoso se molti proiettili sono vivi allo stesso tempo. Potremmo sostituire ImmutableSetcon una struttura ad albero come scala.collection.immutable.TreeSetper ridurre il problema.
Daniel Lubarov,

2
Ed ImmutableImageè anche peggio, dal momento che copia un grande raster quando viene modificato. Ci sono alcune cose che potremmo fare per ridurre anche questo problema, ma penso che sarebbe molto pratico scrivere semplicemente il codice di rendering in stile imperativo (anche i programmatori Haskell lo fanno normalmente).
Daniel Lubarov,

4

Bene, stai ostacolando i tuoi sforzi usando LWJGL - niente contro di esso, ma imporrà modi di dire non funzionali.

La tua ricerca è in linea con ciò che consiglierei, comunque. Gli "eventi" sono ben supportati nella programmazione funzionale attraverso concetti come la programmazione reattiva funzionale o la programmazione del flusso di dati. Puoi provare Reactive , una libreria FRP per Scala, per vedere se può contenere i tuoi effetti collaterali.

Inoltre, togli una pagina da Haskell: usa le monadi per incapsulare / isolare gli effetti collaterali. Vedi monadi di stato e IO.


Tyvm per la tua risposta. Non sono sicuro di come ottenere l'ingresso tastiera / mouse e l'uscita grafica / audio da Reactive. È lì e mi manca solo? Per quanto riguarda il tuo riferimento all'utilizzo di una monade, sto solo imparando su di loro e ancora non capisco completamente cosa sia una monade.
chaotic3quilibrium

3

Le parti non deterministiche (per me) stanno gestendo il flusso di input dell'utente ... gestendo l'output (sia grafico che audio).

Sì, l'IO è effetti collaterali non deterministici e "all about". Questo non è un problema in un linguaggio funzionale non puro come Scala.

gestire il recupero di un valore casuale per determinare gli spari di proiettili alieni

È possibile trattare l'output di un generatore di numeri pseudocasuali come una sequenza infinita ( Seqin Scala).

...

Dove, in particolare, vedi la necessità di mutabilità? Se posso anticipare, potresti pensare che i tuoi folletti abbiano una posizione nello spazio che varia nel tempo. Potresti trovare utile pensare alle "cerniere" in un simile contesto: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php


Non so nemmeno come strutturare il codice iniziale in modo che sia una programmazione funzionale idiomatica. Dopo ciò, non capisco la tecnica corretta (o preferita) per aggiungere il codice "impuro". Sono consapevole che posso usare Scala come "Java senza punto e virgola". Non voglio farlo. Voglio imparare come FP gestisce un ambiente dinamico molto semplice senza fare affidamento su perdite di mutabilità di valore o di tempo. Ha senso?
chaotic3quilibrium
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.