Enfoque de programación funcional para un juego simplificado usando Scala y LWJGL

11

Yo, un programador imperativo de Java, quisiera entender cómo generar una versión simple de Space Invaders basada en los principios de diseño de la Programación Funcional (en particular, la Transparencia Referencial). Sin embargo, cada vez que intento pensar en un diseño, me pierdo en el pantano de la mutabilidad extrema, la misma mutabilidad que los puristas de la programación funcional rechazan.

Como un intento de aprender la Programación Funcional, decidí intentar crear un juego interactivo 2D muy simple, Space Invader (nota la falta de plural), en Scala usando el LWJGL . Estos son los requisitos para el juego básico:

  1. Usuario enviado en la parte inferior de la pantalla movido a izquierda y derecha por las teclas "A" y "D" respectivamente

  2. La bala de la nave del usuario disparada hacia arriba activada por la barra espaciadora con una pausa mínima entre disparos de .5 segundos

  3. Bala de nave alienígena disparada directamente hacia abajo activada por un tiempo aleatorio de .5 a 1.5 segundos entre disparos

Las cosas intencionalmente excluidas del juego original son alienígenas WxH, barreras de defensa degradables x3, un platillo de alta velocidad en la parte superior de la pantalla.

Bien, ahora al dominio del problema real. Para mí, todas las partes deterministas son obvias. Son las partes no deterministas las que parecen estar bloqueando mi capacidad de considerar cómo acercarme. Las partes deterministas son la trayectoria de la bala una vez que existen, el movimiento continuo del extraterrestre y la explosión debido a un golpe en cualquiera (o ambos) de la nave del jugador o el extraterrestre. Las partes no deterministas (para mí) están manejando el flujo de entrada del usuario, manejando la obtención de un valor aleatorio para determinar disparos de bala alienígenas y manejando la salida (tanto gráficos como sonido).

Puedo hacer (y he hecho) mucho de este tipo de desarrollo de juegos a lo largo de los años. Sin embargo, todo fue del paradigma imperativo. Y LWJGL incluso proporciona una versión Java muy simple de los invasores del espacio (de los cuales comencé a moverme a Scala usando Scala como Java sin punto y coma).

Aquí hay algunos enlaces que hablan sobre esta área, de los cuales ninguno parece haber tratado directamente las ideas de una manera que una persona proveniente de la programación Java / Imperative entendería:

  1. Retrogames puramente funcionales, parte 1 de James Hague

  2. Publicación de desbordamiento de pila similar

  3. Juegos Clojure / Lisp

  4. Juegos de Haskell en Stack Overflow

  5. Programación funcional reactiva de Yampa (en Haskell)

Parece que hay algunas ideas en los juegos Clojure / Lisp y Haskell (con fuente). Desafortunadamente, no puedo leer / interpretar el código en modelos mentales que tengan sentido para mi cerebro imperativo Java de mente simple.

Estoy tan entusiasmado con las posibilidades que ofrece FP, que puedo probar las capacidades de escalabilidad de subprocesos múltiples. Siento que si pudiera asimilar cómo se puede implementar algo tan simple como el modelo de tiempo + evento + aleatoriedad para Space Invader, segregando las partes deterministas y no deterministas en un sistema diseñado adecuadamente sin que se convierta en lo que parece una teoría matemática avanzada ; es decir, Yampa, estaría listo. Si aprender el nivel de teoría que Yampa parece requerir para generar con éxito juegos simples es necesario, entonces la sobrecarga de adquirir toda la capacitación y el marco conceptual necesarios superarán ampliamente mi comprensión de los beneficios de la PF (al menos para este experimento de aprendizaje demasiado simplificado) )

Cualquier comentario, modelos propuestos, métodos sugeridos para abordar el dominio del problema (más específico que las generalidades cubiertas por James Hague) sería muy apreciado.

equilibrio caótico
fuente
1
He eliminado la parte de tu blog de la pregunta, porque no era esencial para la pregunta en sí. Siéntase libre de incluir un enlace a un artículo de seguimiento cuando llegue a escribirlo.
Yannis
@ Yannis - Lo tengo. Tyvm!
chaotic3quilibrium
Usted solicitó Scala, por lo que esto es solo un comentario. Caves of Clojure es, en mi opinión, una lectura manejable sobre cómo implementar un estilo FP roguelike. Maneja el estado devolviendo una instantánea del mundo que el autor puede probar. Eso es muy bonito. Tal vez pueda navegar a través de las publicaciones y ver si alguna parte de su implementación es fácilmente transferible a Scala
IAE

Respuestas:

5

Una implementación idiomática Scala / LWJGL de Space Invaders no se parecería tanto a una implementación Haskell / OpenGL. Escribir una implementación de Haskell podría ser un mejor ejercicio en mi opinión. Pero si desea seguir con Scala, aquí hay algunas ideas sobre cómo escribirlo en un estilo funcional.

Intente usar solo objetos inmutables. Podría tener un Gameobjeto que contenga a Player, a Set[Invader](asegúrese de usar immutable.Set), etc. Dé Playerun update(state: Game): Player(también podría tomar depressedKeys: Set[Int], etc.), y dé a las otras clases métodos similares.

Por aleatoriedad, scala.util.Randomno es inmutable como el de Haskell System.Random, pero podrías hacer tu propio generador inmutable. Este es ineficiente pero demuestra la idea.

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

Para la entrada y representación del teclado / mouse, no hay forma de evitar las funciones impuras. También son impuros en Haskell, simplemente están encapsulados en IOetc., de modo que sus objetos de función reales son técnicamente puros (no leen ni escriben el estado ellos mismos, describen las rutinas que sí, y el sistema de tiempo de ejecución ejecuta esas rutinas) .

Simplemente no ponga código de E / S en sus objetos inmutables como Game, Playery Invader. Puedes dar Playerun rendermétodo, pero debería verse como

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

Desafortunadamente, esto no encaja bien con LWJGL ya que está tan basado en el estado, pero puedes construir tus propias abstracciones sobre él. Podría tener una ImmutableCanvasclase que contenga un AWT Canvas, y su blit(y otros métodos) podrían clonar el subyacente Canvas, pasarlo Display.setParent, luego realizar el renderizado y devolver el nuevo Canvas(en su contenedor inmutable).


Actualización : Aquí hay un código Java que muestra cómo haría esto. (Hubiera escrito casi el mismo código en Scala, excepto que un conjunto inmutable está incorporado y algunos bucles para cada uno podrían ser reemplazados con mapas o pliegues). Hice un jugador que se mueve y dispara balas, pero yo no agregó enemigos ya que el código ya se estaba alargando. Hice casi todo lo de copiar y escribir : creo que este es el concepto más 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) {}
    }
  }
}
Daniel Lubarov
fuente
2
Agregué un código Java, ¿ayuda? Si el código parece extraño, miraría algunos ejemplos más pequeños de clases inmutables de copia en escritura. Esto parece una explicación decente.
Daniel Lubarov
2
@ chaotic3quilibrium es solo un identificador normal. A veces lo uso en lugar de argssi el código ignora los argumentos. Perdón por la confusión innecesaria.
Daniel Lubarov
2
Sin preocupaciones. Simplemente asumí eso y seguí adelante. Jugué con tu código de ejemplo durante ayer. Creo que tengo el truco de la idea. Ahora, me pregunto si me falta algo más. El número de objetos temporales es enorme. Cada tic genera un marco que muestra un GameState. Y llegar a ese GameState desde el GameState del tick anterior implica generar una cantidad de instancias de GameState que intervienen, cada una con un pequeño ajuste del GameState anterior.
chaotic3quilibrium
3
Sí, es bastante derrochador. No creo que las GameStatecopias sean tan costosas, a pesar de que se hacen varias en cada tic, ya que son ~ 32 bytes cada una. Pero copiar el ImmutableSets podría ser costoso si muchas balas están vivas al mismo tiempo. Podríamos reemplazarlo ImmutableSetcon una estructura de árbol scala.collection.immutable.TreeSetpara disminuir el problema.
Daniel Lubarov
2
Y ImmutableImagees aún peor, ya que copia una gran trama cuando se modifica. Hay algunas cosas que podríamos hacer para disminuir ese problema también, pero creo que sería más práctico simplemente escribir el código de representación en un estilo imperativo (incluso los programadores de Haskell normalmente lo hacen).
Daniel Lubarov
4

Bueno, estás reduciendo tus esfuerzos al usar LWJGL, nada en contra, pero impondrá expresiones idiomáticas no funcionales.

Sin embargo, su investigación está en línea con lo que recomendaría. Los "eventos" están bien soportados en la programación funcional a través de conceptos como la programación funcional reactiva o la programación de flujo de datos. Puede probar Reactive , una biblioteca de FRP para Scala, para ver si puede contener sus efectos secundarios.

Además, saque una página de Haskell: use mónadas para encapsular / aislar los efectos secundarios. Ver estado y mónadas IO.

Daniel C. Sobral
fuente
Tyvm por su respuesta. No estoy seguro de cómo obtener la entrada del teclado / mouse y la salida de gráficos / sonido de Reactive. ¿Está ahí y me lo estoy perdiendo? En cuanto a su referencia al uso de una mónada, ahora estoy aprendiendo sobre ellas y todavía no entiendo completamente qué es una mónada.
chaotic3quilibrium
3

Las partes no deterministas (para mí) están manejando el flujo de entrada del usuario ... manejando la salida (tanto gráficos como sonido).

Sí, IO no es determinista y tiene "efectos secundarios". Eso no es un problema en un lenguaje funcional no puro como Scala.

manejo de búsqueda de un valor aleatorio para determinar disparos de bala alienígena

Puede tratar la salida de un generador de números pseudoaleatorios como una secuencia infinita ( Seqen Scala).

...

¿Dónde, en particular, ves la necesidad de mutabilidad? Si puedo anticipar, podrías pensar que tus sprites tienen una posición en el espacio que varía con el tiempo. Puede resultarle útil pensar en "cremalleras" en este contexto: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php

Larry OBrien
fuente
Ni siquiera sé cómo estructurar el código inicial para que sea una programación funcional idiomática. Después de eso, no entiendo la técnica correcta (o preferida) para agregar el código "impuro". Soy consciente de que puedo usar Scala como "Java sin punto y coma". No quiero hacer eso. Quiero aprender cómo FP aborda un entorno dinámico muy simple sin depender del tiempo o de las pérdidas de valor de mutabilidad. ¿Eso tiene sentido?
chaotic3quilibrium