Подход к функциональному программированию для упрощенной игры с использованием Scala и LWJGL

11

Я, императивный программист на Java, хотел бы понять, как создать простую версию Space Invaders, основанную на принципах проектирования функционального программирования (в частности, ссылочной прозрачности). Однако каждый раз, когда я пытаюсь придумать дизайн, я теряюсь в муре чрезвычайной изменчивости, той же изменчивости, которой избегают пуристы функционального программирования.

В качестве попытки освоить функциональное программирование я решил попытаться создать очень простую 2D интерактивную игру Space Invader (обратите внимание на отсутствие множественного числа) в Scala с использованием LWJGL . Вот требования к основной игре:

  1. Пользовательский корабль внизу экрана перемещается влево и вправо с помощью клавиш «A» и «D» соответственно

  2. Пуля пользовательского корабля, выпущенная прямо вверх, активируется пробелом с минимальной паузой между выстрелами, чтобы быть .5 секунд

  3. Пуля инопланетного корабля, выпущенная прямо вниз, активируется случайным образом от 0,5 до 1,5 секунд между выстрелами

В оригинальной игре намеренно не учтены инопланетяне WxH, разрушаемые защитные барьеры х3, высокоскоростной корабль с блюдцем в верхней части экрана.

Хорошо, теперь к актуальной проблемной области. Для меня все детерминированные части очевидны. Это недетерминированные части, которые, кажется, блокируют мою способность думать, как приблизиться. Детерминированные части - это траектория полета пули, как только они существуют, непрерывное движение инопланетянина и взрыв из-за удара по одному или обоим корабля игрока или пришельца. Недетерминированные части (для меня) обрабатывают поток пользовательского ввода, обрабатывают выборку случайного значения для определения стрельбы инопланетными пулями и обработку вывода (как графики, так и звука).

Я могу сделать (и сделал) много такого типа разработки игр на протяжении многих лет. Однако все это было из императивной парадигмы. И LWJGL даже предоставляет очень простую Java-версию Space invaders (о которой я начал переходить на Scala, используя Scala как Java-без точек с запятой).

Вот некоторые ссылки, которые обсуждают эту область, о которой никто, похоже, не имел непосредственного отношения к идеям таким образом, чтобы человек, пришедший из Java / императивного программирования, мог понять:

  1. Чисто Функциональные Retrogames, Часть 1 Джеймсом Хейгом

  2. Подобный пост переполнения стека

  3. Clojure / Lisp Games

  4. Игры на Haskell при переполнении стека

  5. Функциональное реактивное программирование Ямпы (на Хаскеле)

Похоже, что есть некоторые идеи в играх Clojure / Lisp и Haskell (с исходным кодом). К сожалению, я не в состоянии читать / интерпретировать код в ментальных моделях, которые имеют какой-то смысл для моего простого мышления в Java.

Я так взволнован возможностями, которые предлагает FP, я могу просто попробовать возможности многопоточной масштабируемости. Я чувствую, что смог бы воплотить в жизнь что-то простое, например, модель время + событие + случайность для Space Invader, разделяя детерминированные и недетерминированные части в правильно спроектированной системе без превращения ее в нечто вроде продвинутой математической теории. ; то есть Ямпа, я бы поставил. Если изучение уровня теории Yampa, по-видимому, требует успешного создания простых игр, необходимо, тогда накладные расходы на приобретение всей необходимой учебной и концептуальной основы значительно перевесят мое понимание преимуществ FP (по крайней мере для этого упрощенного эксперимента по обучению). ).

Любая обратная связь, предлагаемые модели, предлагаемые методы подхода к проблемной области (более конкретные, чем общие положения, охватываемые Джеймсом Хейгом), будут высоко оценены.

chaotic3quilibrium
источник
1
Я удалил часть вопроса о вашем блоге из вопроса, потому что он не был существенным для самого вопроса. Не стесняйтесь включать ссылку на последующую статью, когда вы собираетесь ее написать.
Яннис
@ Яннис - Понял. Tyvm!
chaotic3quilibrium
Вы спрашивали о Scala, поэтому это всего лишь комментарий. «Пещеры Clojure» - это читаемая статья о том, как реализовать стиль FP в стиле roguelike. Он обрабатывает состояние, возвращая снимок мира, который автор может затем проверить. Это круто. Может быть, вы можете просмотреть посты и посмотреть, легко ли можно перенести какие-либо части его реализации в Scala
IAE

Ответы:

5

Идиоматическая реализация Space Invaders для Scala / LWJGL не будет выглядеть так же, как реализация на Haskell / OpenGL. Написание реализации на Haskell может быть лучшим упражнением, на мой взгляд. Но если вы хотите придерживаться Scala, вот несколько идей о том, как написать его в функциональном стиле.

Старайтесь использовать только неизменные объекты. Вы можете иметь Gameобъект , который держит Player, А Set[Invader](не забудьте использовать immutable.Set) и т.д. Дайте (он может также принимать и т.д.), и дать другим классам подобные методы.Playerupdate(state: Game): PlayerdepressedKeys: Set[Int]

Для случайности scala.util.Randomне является неизменным, как у Хаскелла System.Random, но вы можете сделать свой собственный неизменный генератор. Этот неэффективен, но он демонстрирует идею.

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

Для ввода с клавиатуры / мыши и рендеринга нет способа обойти нечистые функции. Они также нечисты в Haskell, они просто инкапсулированы IOи т. Д., Так что ваши действительные функциональные объекты технически чисты (они сами не читают и не записывают состояние, они описывают подпрограммы, которые выполняют, и система времени выполнения выполняет эти подпрограммы) ,

Только не помещайте код ввода / вывода в ваши неизменяемые объекты, такие как Game, Playerи Invader. Вы можете дать Playerна renderметод, но он должен выглядеть

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

К сожалению, это не очень подходит для LWJGL, так как он основан на состоянии, но вы можете построить свои собственные абстракции поверх него. У вас может быть ImmutableCanvasкласс, содержащий AWT Canvas, и его blit(и другие методы) могут клонировать базовый объект Canvas, передавать его Display.setParent, затем выполнять рендеринг и возвращать новый Canvas(в вашей неизменяемой оболочке).


Обновление : вот некоторый код Java, показывающий, как я поступил бы по этому поводу. (Я написал бы почти такой же код в Scala, за исключением того, что встроенный неизменяемый набор и несколько циклов for-each можно заменить картами или сгибами.) Я сделал игрока, который перемещается и запускает пули, но я не добавлял врагов, так как код уже становился длинным. Я сделал почти все, что копировал при записи - я думаю, что это самая важная концепция.

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
Я добавил немного кода Java - это помогает? Если код выглядит странно, я бы посмотрел несколько небольших примеров неизменных классов копирования при записи. Это выглядит как достойное объяснение.
Даниэль Любаров
2
@ chaotic3quilibrium это просто нормальный идентификатор. Я иногда использую его вместо того, argsчтобы код игнорировал аргументы. Извините за ненужную путаницу.
Даниэль Любаров
2
Без проблем. Я просто предположил, что и пошел дальше. Я играл с вашим примером кода в то время как вчера. Я думаю, что у меня есть идея. Теперь мне интересно, не упустил ли я что-то еще. Количество временных объектов огромно. Каждый тик генерирует кадр, который отображает GameState. И чтобы добраться до этого GameState из GameState предыдущего тика, нужно сгенерировать несколько промежуточных экземпляров GameState, каждый с одной небольшой настройкой из предыдущего GameState.
chaotic3quilibrium
3
Да, это довольно расточительно. Я не думаю, что GameStateкопии будут такими дорогостоящими, даже если по нескольким тикам делаются по несколько, так как они ~ 32 байта каждая. Но копирование ImmutableSetможет быть дорогостоящим, если много пуль живы одновременно. Мы могли бы заменить ImmutableSetна древовидную структуру, scala.collection.immutable.TreeSetчтобы уменьшить проблему.
Даниэль Любаров
2
И ImmutableImageчто еще хуже, поскольку при модификации он копирует большой растр. Есть некоторые вещи, которые мы могли бы сделать, чтобы уменьшить эту проблему, но я думаю, что было бы наиболее практичным просто написать код рендеринга в императивном стиле (даже программисты на Haskell обычно делают это).
Даниэль Любаров
4

Ну, вы сдерживаете свои усилия, используя LWJGL - ничего против этого, но это навязывает нефункциональные идиомы.

Однако ваше исследование соответствует тому, что я бы порекомендовал. «События» хорошо поддерживаются в функциональном программировании через такие понятия, как функциональное реактивное программирование или программирование потока данных. Вы можете попробовать Reactive , библиотеку FRP для Scala, чтобы узнать, может ли она содержать ваши побочные эффекты.

Кроме того, возьмите страницу из Haskell: используйте монады, чтобы инкапсулировать / изолировать побочные эффекты. Смотрите состояние и IO монады.

Даниэль С. Собрал
источник
Тывм за ваш ответ. Я не уверен, как получить ввод с клавиатуры / мыши и вывод графики / звука из Reactive. Это там, и я просто скучаю по нему? Что касается вашей ссылки на использование монады - я только сейчас узнаю о них и до сих пор не до конца понимаю, что такое монада.
chaotic3quilibrium
3

Недетерминированные части (для меня) обрабатывают поток пользовательского ввода ... обрабатывают вывод (как графику, так и звук).

Да, IO является недетерминированным и «все о» побочных эффектах. Это не проблема в не чистом функциональном языке, таком как Scala.

обработка извлечения случайного значения для определения стрельбы инопланетными пулями

Вы можете рассматривать выход генератора псевдослучайных чисел как бесконечную последовательность ( Seqв Scala).

...

Где, в частности, вы видите необходимость изменчивости? Если я могу предвидеть, вы можете думать о своих спрайтах как о положении в пространстве, которое меняется со временем. Вам может быть полезно подумать о «молнии» в таком контексте: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php

Ларри Обриен
источник
Я даже не знаю, как структурировать исходный код так, чтобы это было идиоматическое функциональное программирование. После этого я не понимаю правильную (или предпочтительную) технику добавления в «нечистый» код. Я знаю, что могу использовать Scala как «Java без точек с запятой». Я не хочу этого делать. Я хочу узнать, как FP обращается к очень простой динамической среде, не полагаясь на утечки времени или изменчивости значений. Имеет ли это смысл?
chaotic3quilibrium