Programowanie funkcjonalne dla uproszczonej gry z wykorzystaniem Scali i LWJGL


11

Ja, bezwzględny programista Java, chciałbym zrozumieć, jak wygenerować prostą wersję Space Invaders w oparciu o zasady projektowania programowania funkcjonalnego (w szczególności przezroczystość referencyjną). Jednak za każdym razem, gdy próbuję wymyślić jakiś projekt, gubię się w bagnie ekstremalnej zmienności, tej samej zmienności, której unika funkcjonalny programista.

Próbując nauczyć się programowania funkcjonalnego, postanowiłem stworzyć w Scali bardzo prostą interaktywną grę 2D, Space Invader (zwróć uwagę na brak liczby mnogiej), używając LWJGL . Oto wymagania dotyczące podstawowej gry:

  1. Użytkownik wysyłany na dole ekranu poruszał się w lewo i w prawo odpowiednio za pomocą klawiszy „A” i „D”

  2. Kula statku użytkownika wystrzelona prosto w górę aktywowana spacją z minimalną przerwą między strzałami wynoszącą 0,5 sekundy

  3. Kula statku kosmicznego wystrzelona prosto w dół aktywowana losowym czasem od 0,5 do 1,5 sekundy między strzałami

Rzeczy, które celowo pominięto w oryginalnej grze, to kosmici WxH, degradowalne bariery obronne x3, szybki statek spodka u góry ekranu.

OK, teraz do faktycznej domeny problemów. Dla mnie wszystkie części deterministyczne są oczywiste. To niedeterministyczne części wydają się blokować moją zdolność do zastanowienia się, jak podejść. Deterministyczne części to trajektoria pocisku, gdy już istnieją, ciągły ruch kosmity i eksplozja spowodowana trafieniem jednego (lub obu) statku gracza lub kosmity. Części niedeterministyczne (według mnie) obsługują strumień danych wejściowych użytkownika, pobierają losową wartość w celu określenia wystrzeliwania pocisków przez kosmitów i obsługi wyjścia (zarówno grafiki, jak i dźwięku).

Mogę robić (i robiłem) wiele tego rodzaju rozwoju gier na przestrzeni lat. Wszystko to jednak wynikało z paradygmatu imperatywu. A LWJGL zapewnia nawet bardzo prostą wersję Java najeźdźców kosmicznych (z których zacząłem przenosić się do Scali, używając Scali jako języka Java bez średników).

Oto kilka linków, które mówią o tym obszarze, z których żaden nie wydaje się bezpośrednio zajmować pomysłami w sposób zrozumiały dla osoby pochodzącej z programowania Java / Imperative:

  1. Czysto funkcjonalne Retrogames, część 1 James Hague

  2. Podobny post przepełnienia stosu

  3. Gry Clojure / Lisp

  4. Gry Haskell o przepełnieniu stosu

  5. Funkcjonalne programowanie reaktywne Yampy (w Haskell)

Wygląda na to, że w grach Clojure / Lisp i Haskell są pewne pomysły (ze źródłem). Niestety, nie jestem w stanie odczytać / zinterpretować kodu w modelach mentalnych, które mają sens dla mojego prostego mózgu imperatywnego Java.

Jestem tak podekscytowany możliwościami oferowanymi przez FP, że po prostu próbuję możliwości wielowątkowej skalowalności. Wydaje mi się, że potrafiłem sobie wyobrazić, jak można wdrożyć coś tak prostego jak model czas + zdarzenie + losowość dla Space Invader, segregując deterministyczne i niedeterministyczne części w odpowiednio zaprojektowanym systemie, bez przekształcania się w coś, co wydaje się zaawansowaną teorią matematyczną ; tj. Yampa, byłbym ustawiony. Jeśli uczenie się poziomu teorii, który wydaje się wymagać od Yampa, aby z powodzeniem generować proste gry, jest konieczne, wówczas narzut związany z uzyskaniem wszystkich niezbędnych szkoleń i ram koncepcyjnych znacznie przewyższy moje rozumienie korzyści płynących z FP (przynajmniej w przypadku tego zbyt uproszczonego eksperymentu uczenia się ).

Wszelkie informacje zwrotne, proponowane modele, sugerowane metody podejścia do dziedziny problemowej (bardziej szczegółowe niż ogólne informacje objęte przez Jamesa Hague'a) byłyby bardzo mile widziane.


1
Usunąłem część z twojego bloga z pytania, ponieważ nie było to istotne dla samego pytania. Dołącz do linku do kolejnego artykułu, gdy będziesz go pisać.
yannis

@Yannis - Rozumiem. Tyvm!
chaotic3quilibrium

Poprosiłeś o Scalę, dlatego jest to tylko komentarz. Caves of Clojure to realistyczna lektura na temat tego, jak wdrożyć roguelowy styl FP. Obsługuje stan, zwracając migawkę świata, którą autor może następnie przetestować. To fajnie. Być może możesz przejrzeć posty i sprawdzić, czy jakieś części jego implementacji można łatwo przenieść do Scali
IAE

Odpowiedzi:


5

Idiomatyczna implementacja Scala / LWJGL Space Invaders nie wyglądałaby tak bardzo jak implementacja Haskell / OpenGL. Moim zdaniem, lepszym ćwiczeniem może być napisanie implementacji Haskell. Ale jeśli chcesz trzymać się Scali, oto kilka pomysłów, jak napisać ją w funkcjonalnym stylu.

Staraj się używać tylko niezmiennych obiektów. Możesz mieć Gameobiekt, który zawiera a Player, a Set[Invader](pamiętaj, aby użyć immutable.Set), itd. Daj Playeran update(state: Game): Player(może również zająć depressedKeys: Set[Int]itp.), I daj innym klasom podobne metody.

Losowość scala.util.Randomnie jest niezmienna jak w przypadku Haskella System.Random, ale można stworzyć własny, niezmienny generator. Ten jest nieefektywny, ale pokazuje pomysł.

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

W przypadku wprowadzania i renderowania za pomocą klawiatury / myszy nie ma mowy o wywoływaniu nieczystych funkcji. Są one również nieczyste w Haskell, są po prostu zamknięte w IOitp., Dzięki czemu rzeczywiste obiekty funkcji są technicznie czyste (same nie czytają ani nie piszą stanu, opisują procedury, które je wykonują, a system wykonawczy wykonuje te procedury) .

Po prostu nie umieścić I / O w swoim kod niezmiennych obiektów takich jak Game, Playeri Invader. Można dać Playersię rendermetody, ale to powinno wyglądać

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

Niestety nie pasuje to do LWJGL, ponieważ jest tak oparte na stanie, ale można na nim budować własne abstrakcje. Możesz mieć ImmutableCanvasklasę, która zawiera AWT Canvas, a jej blit(i inne metody) mogą sklonować bazę Canvas, przekazać ją Display.setParent, a następnie wykonać renderowanie i zwrócić nową Canvas(w niezmiennym opakowaniu).


Aktualizacja : Oto kod Java pokazujący, jak bym sobie z tym poradził. (Napisałbym prawie taki sam kod w Scali, z wyjątkiem tego, że wbudowany jest niezmienny zestaw i kilka pętli dla każdej z nich można zastąpić mapami lub pasami.) Stworzyłem gracza, który porusza się i strzela pociskami, ale ja nie dodawał wrogów, ponieważ kod był już długi. Zrobiłem prawie wszystko, co napisałem na piśmie - myślę, że to najważniejsza koncepcja.

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
Dodałem trochę kodu Java - czy to pomaga? Jeśli kod wygląda dziwnie, spojrzałbym na kilka mniejszych przykładów niezmiennych klas kopiowania przy zapisie. To wygląda na porządne wytłumaczenie.
Daniel Lubarov

2
@ chaotic3quilibrium to tylko normalny identyfikator. Czasami używam go zamiast, argsjeśli kod ignoruje argumenty. Przepraszam za niepotrzebne zamieszanie.
Daniel Lubarov

2
Bez obaw. Po prostu to założyłem i ruszyłem dalej. Wczoraj bawiłem się twoim przykładowym kodem. Myślę, że mam pomysł na ten pomysł. Teraz zastanawiam się, czy brakuje mi czegoś innego. Liczba obiektów tymczasowych jest ogromna. Każdy tik generuje ramkę, która wyświetla GameState. Aby dostać się do GameState z GameState poprzedniego kleszcza, należy wygenerować szereg interweniujących instancji GameState, z których każda ma jedną drobną korektę z poprzedniej GameState.
chaotic3quilibrium

3
Tak, to dość marnotrawstwo. Nie sądzę, że GameStatekopie byłyby tak kosztowne, mimo że po jednym tiku wykonanych jest kilka, ponieważ każdy ma ~ 32 bajty. Ale kopiowanie ImmutableSets może być kosztowne, jeśli wiele pocisków żyje w tym samym czasie. Możemy zastąpić ImmutableSetstrukturę drzewa, scala.collection.immutable.TreeSetaby zmniejszyć problem.
Daniel Lubarov

2
I ImmutableImagejest jeszcze gorzej, ponieważ kopiuje duży raster po zmodyfikowaniu. Jest kilka rzeczy, które moglibyśmy zrobić, aby zmniejszyć ten problem, ale myślę, że najbardziej praktyczne byłoby napisanie kodu renderującego w trybie rozkazującym (nawet programiści Haskell zwykle to robią).
Daniel Lubarov

4

Cóż, hamujesz swoje wysiłki, używając LWJGL - nic przeciwko temu, ale narzuci to niefunkcjonalne idiomy.

Twoje badania są jednak zgodne z tym, co zaleciłbym. „Zdarzenia” są dobrze wspierane w programowaniu funkcjonalnym poprzez pojęcia takie jak funkcjonalne programowanie reaktywne lub programowanie przepływu danych. Możesz wypróbować Reactive , bibliotekę FRP dla Scali, aby sprawdzić, czy może zawierać efekty uboczne.

Wyjmij też stronę z Haskell: używaj monad do zamykania / izolowania efektów ubocznych. Zobacz monady stanowe i we / wy.


Tyvm za twoją odpowiedź. Nie jestem pewien, jak uzyskać wejście klawiatury / myszy i wyjście graficzne / dźwiękowe z Reactive. Czy tam jest i po prostu mi tego brakuje? Co do twojego odniesienia do używania monady - dopiero teraz się o nich uczę i nadal nie rozumiem do końca, czym jest monada.
chaotic3quilibrium

3

Części niedeterministyczne (dla mnie) obsługują strumień danych wejściowych użytkownika ... obsługują dane wyjściowe (zarówno grafikę, jak i dźwięk).

Tak, IO jest niedeterministyczna i „wszystko o” skutkach ubocznych. Nie jest to problemem w nieczystym języku funkcjonalnym, takim jak Scala.

obsługa pobierania losowej wartości w celu określenia wystrzeliwania pocisków przez obcych

Możesz traktować wyjście generatora liczb pseudolosowych jako sekwencję nieskończoną ( Seqw Scali).

...

Gdzie w szczególności widzisz potrzebę zmienności? Jeśli mogę przewidzieć, możesz pomyśleć o swoich duszkach jako o pozycji w przestrzeni, która zmienia się w czasie. Przydatne może być myślenie o „zamkach błyskawicznych” w takim kontekście: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php


Nie wiem nawet, jak zbudować kod początkowy, aby był to idiomatyczne programowanie funkcjonalne. Po tym nie rozumiem poprawnej (lub preferowanej) techniki dodawania kodu „nieczystego”. Wiem, że mogę używać Scali jako „Java bez średników”. Nie chcę tego robić. Chcę dowiedzieć się, w jaki sposób FP radzi sobie z bardzo prostym, dynamicznym środowiskiem bez polegania na przeciekach czasu i wartości. Czy to ma sens?
chaotic3quilibrium
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.