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


11

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

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

  1. Користувач, що перебуває в нижній частині екрана, переміщується ліворуч та праворуч клавішами "A" та "D" відповідно

  2. Куля користувача-судна вистрілила прямо вгору, активована пробілом з мінімальною паузою між пострілами до 5 секунд

  3. Куля чужорідного корабля стріляла прямо вниз, активована випадковим часом між пострілами від 5 до 1,5 секунд

Речі, які навмисно залишилися в оригінальній грі, - прибульці WxH, розкладаються захисні бар'єри x3, швидкісний корабель із блюдцем у верхній частині екрана.

Гаразд, тепер до фактичної проблемної області. Для мене всі детерміновані частини очевидні. Саме недетерміновані частини, здається, блокують мою здатність розглядати, як підходити. Детерміновані частини - це траєкторія кулі, коли вони існують, безперервний рух прибульця і ​​вибух внаслідок попадання на (або на обидва) корабля гравця або на прибульця. Недетерміновані частини (для мене) обробляють потік введення користувача, обробляючи отримання випадкового значення для визначення чужоземних стрільб кулі та обробки виходу (графічного та звукового).

Я можу зробити (і зробив) багато такого розвитку ігор протягом багатьох років. Проте все це було з імперативної парадигми. А LWJGL навіть пропонує дуже просту версію Java з космічних загарбників (з яких я почав переходити до Scala, використовуючи Scala як Java без крапки з комою).

Ось декілька посилань, які розповідають про цю сферу, про яку, схоже, ніхто безпосередньо не займався ідеями таким чином, щоб людина, що походить з програмування Java / Imperative, зрозуміла:

  1. Чисто функціональні ретрогри, частина 1 Джеймса Хейга

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

  3. Ігри Clojure / Lisp

  4. Haskell Games на стек переповнення

  5. Функціональне реактивне програмування Yampa (в Haskell)

Схоже, в іграх Clojure / Lisp та Haskell (з джерелом) є деякі ідеї. На жаль, я не в змозі прочитати / інтерпретувати код у ментальні моделі, які мають будь-який сенс для мого простого розуму Java імперативного мозку.

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

Будь-які відгуки, запропоновані моделі, запропоновані методи наближення до проблемної області (більш конкретні, ніж загальні аспекти, які охоплює Джеймс Хейг), будуть дуже вдячні.


1
Я вилучив із вашого запитання частину вашого блогу, оскільки це не було важливо для самого питання. Сміливо додайте посилання на подальшу статтю, коли ви збираєтесь писати її.
янніс

@Yannis - Зрозумів. Тивм!
хаотичний3рівноваги

Ви попросили Scala, тому це лише коментар. Печери Clojure - це керований текст, який можна прочитати про те, як реалізувати FP стиль FP. Він обробляє стан, повертаючи знімок світу, який автор може потім перевірити. Це досить круто. Можливо, ви можете переглядати публікації та бачити, чи якісь частини його реалізації легко переносяться на Scala
IAE

Відповіді:


5

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

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

Для випадковості, scala.util.Randomне є незмінним , як в Haskell System.Random, але ви можете зробити свій власний генератор immmutable. Цей неефективний, але це демонструє ідею.

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

Для введення та рендерингу клавіатури / миші немає можливості обіймати нечисті функції. Вони також нечисті в Haskell, вони просто інкапсульовані IOтощо, так що ваші фактичні об'єкти функцій є технічно чистими (вони самі не читають і не записують стан, вони описують підпрограми, які роблять, а система виконання виконує ці процедури) .

Просто не ставте I / O код в ваших незмінних об'єктах , таких як Game, Playerі Invader. Ви можете дати Playerна renderметод, але він повинен виглядати

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

На жаль, це не добре відповідає LWJGL, оскільки воно настільки засноване на державі, але ви можете побудувати свої абстракції над цим. У вас може бути ImmutableCanvasклас, який містить AWT Canvas, і його blit(та інші методи) могли б клонувати базовий Canvas, передати його Display.setParent, потім виконати візуалізацію та повернути нове Canvas(у вашій незмінній обгортці).


Оновлення : Ось код Java, який показує, як я б це зробив. (Я би написав майже той самий код у Scala, за винятком того, що незмінний набір вбудований, і декілька для кожної петлі можна замінити картами або складками.) Я створив гравця, який рухається навколо та стріляє кулями, але я не додав ворогів, оскільки код уже давно стає. Я зробив майже все, що потрібно скопіювати - я думаю, що це найважливіша концепція.

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.
хаотична рівновага

3
Так, це досить марно. Я не думаю, що GameStateкопії будуть такими дорогими, навіть якщо по декілька зроблено кожен галочок, оскільки вони мають по 32 байти. Але копіювання ImmutableSets може бути дорогим, якщо багато куль одночасно живі. Ми могли б замінити ImmutableSetструктуру дерева, scala.collection.immutable.TreeSetщоб зменшити проблему.
Даніель Любаров

2
І ImmutableImageще гірше, оскільки він копіює великий растр, коли його модифікують. Є кілька речей, які ми могли б зробити і для зменшення цієї проблеми, але я думаю, що було б найбільш практичним просто написати код візуалізації в імперативному стилі (навіть програмісти Haskell зазвичай роблять це).
Даніель Любаров

4

Ну, ви стримуєте свої зусилля, використовуючи LWJGL - нічого проти цього, але це нав'язує нефункціональні ідіоми.

Однак ваше дослідження відповідає тому, що я рекомендував би. "Події" добре підтримуються у функціональному програмуванні через такі поняття, як функціональне реактивне програмування або програмування потоку даних. Ви можете спробувати Reactive , бібліотеку FRP для Scala, щоб побачити, чи може вона містити ваші побічні ефекти.

Крім того, зніміть сторінку з Haskell: використовуйте монади, щоб капсулювати / ізолювати побічні ефекти. Дивіться монаси штатів та IO.


Tyvm для вашої відповіді. Я не впевнений, як отримати введення клавіатури / миші та графіку / звук від Reactive. Чи є вона, і я її просто пропускаю? Щодо вашої посилання на використання монади - я просто зараз дізнаюся про них і досі не розумію, що таке монада.
хаотична рівновага

3

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

Так, IO є недетермінованим і "все про" побічні ефекти. Це не є проблемою для не чистої функціональної мови, такої як Scala.

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

Ви можете розглянути вихід генератора псевдовипадкових чисел як нескінченну послідовність ( Seqу Scala).

...

Де, зокрема, ви бачите потребу в незмінності? Якщо я можу передбачити, ви можете думати, що ваші спрайти мають положення у просторі, яке змінюється з часом. Вам може бути корисно думати про "блискавки" в такому контексті: http://scienceblogs.com/goodmath/2010/01/zippers_making_functional_upda.php


Я навіть не знаю, як структурувати початковий код так, щоб він був ідіоматичним функціональним програмуванням. Після цього я не розумію правильної (або бажаної) техніки для додавання в "нечистий" код. Я знаю, що я можу використовувати Scala як "Java без крапки з комою". Я не хочу цього робити. Я хочу дізнатися, як FP вирішує дуже просте динамічне середовище, не покладаючись на витоки змінних часу та цінності. Це має сенс?
хаотичний3рівноваги
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.