Leap Motion Java Game - JBlockMotion
Zoltan Ruzman 4931
Abstract
Bevor der dritte Teil der Hand-Tracking Reihe vorgestellt wird, gibt es ein kleines Beispiel, wie Gesten mit den Daten der Leap Motion interpretiert werden können. In dem Spiel JBlockMotion muss ein rechteckiger Block über eine Ebene gekippt und ins Ziel gestellt werden. Die Richtung bestimmt der Spieler, indem er Kreise in die Luft zeichnet. Der übersichtlichkeitshalber wird in dem Spiel auf 3D, Schatten, Animationen, Menüs, etc. verzichtet und lediglich ein Level implementiert.
Spielaufbau
Das Spiel wird mithilfe der Slick2D-Library umgesetzt. Dort stellt die Klasse BasicGame das Grundgerüst zur Spielentwicklung zur Verfügung. Diese enthält die Methoden init(), update() und render(). Die init()-Methode wird zu Beginn automatisch aufgerufen. Dort werden deshalb alle Bilder geladen und die benötigten Objekte initialisiert.
In die Methode update() wird die Steuerung des Spielers geregelt. Vorerst über die Pfeiltasten und anschließend mit der Leap Motion. Verlassen wird das Spiel mit der ESC-Taste. Sofern der Spieler von der Plattform „fällt“ oder sich auf die Zielposition stellt, wird er automatisch auf die Startposition zurückgesetzt.
In der render()-Methode kommt zuerst ins Zentrum des Bildschirms die Plattform, auf welcher sich der Spieler bewegt. Im Anschluss wird der Spieler gezeichnet.
Anzumerken ist, dass in Slick2D update() und render() sequenziell in einer sogenannten Spielschleife (~Endlosschleife) ausgeführt werden. Eine parallele Verarbeitung findet nicht statt und somit müssen die Daten auch nicht synchronisiert werden. Der erste Entwurf der Klasse JBlockMotion enthält nur das Setup des Leap Motion Controllers. Zu Testzwecken ist eine Pfeiltastensteuerung implementiert. Die Navigation mit der Leap Motion folgt erst im Anschluss.
public class JBlockMotion extends BasicGame {
private Layer layer;
private Player player;
public static void main(String args[]) throws SlickException {
LeapApp.init();
LeapApp.setMode(Mode.DYNAMIC_ONE_SIDE);
LeapApp.setMaximumHandNumber(1);
AppGameContainer app = new AppGameContainer(new JBlockMotion()) {
@Override
public void destroy() {
LeapApp.destroy();
super.destroy();
}
};
app.setDisplayMode(LeapApp.getDisplayWidth(), LeapApp.getDisplayHeight(), true);
app.setShowFPS(false);
app.start();
}
public JBlockMotion() {
super("JBlockMotion");
}
@Override
public void init(GameContainer container) throws SlickException {
layer = new Layer();
player = new Player();
player.resetPosition(4, 1);
}
@Override
public void render(GameContainer gc, Graphics g)
throws SlickException {
// Draw layer and player in the middle of the screen
g.translate(LeapApp.getDisplayWidth()/2 -layer.getWidth()/2,
LeapApp.getDisplayHeight()/2 - layer.getHeight()/2);
layer.render(g);
player.render(g);
}
@Override
public void update(GameContainer gc, int delta)
throws SlickException {
Input input = gc.getInput();
if(input.isKeyPressed(Input.KEY_RIGHT)) {
player.move(Direction.RIGHT);
} else if(input.isKeyPressed(Input.KEY_LEFT)) {
player.move(Direction.LEFT);
} else if(input.isKeyPressed(Input.KEY_DOWN)) {
player.move(Direction.DOWN);
} else if(input.isKeyPressed(Input.KEY_UP)) {
player.move(Direction.UP);
}
int[] position = player.getPosition();
if (hasWon(position) || isOutside(position)) {
player.resetPosition(2, 0);
}
}
private boolean hasWon(int[] position) {
return position.length == 2
&& layer.getBlock(position[0], position[1]) == 'G';
}
private boolean isOutside(int[] position) {
for (int i = 0; i < position.length; i += 2) {
if (layer.getBlock(position[i], position[i+1]) == '0') {
player.resetPosition(2, 0);
}
}
return false;
}
}
BlockRenderer
Die Klasse BlockRenderer verwaltet alle Blöcke, welche durch ein char-Kennzeichen eindeutig identifiziert werden können. Ein Image mit folgendem Muster ist zusätzlich hinterlegt:
Die Blöcke werden in der Klasse Layer zu einer Plattform kombiniert, auf welcher sich der Spieler bewegt. Um die Komplexität des Codes gering zu halten, wird die Annahme getroffen, dass alle Images gleich skaliert sind. Die Implementierung sieht folgendermaßen aus:
public class BlockRenderer {
public static final int BLOCK_WIDTH = 100;
private static final BlockRenderer SINGLETON = new BlockRenderer();
private Map<Character, Image> blockImages;
private BlockRenderer() {
try {
blockImages = new HashMap<>();
blockImages.put('B', new Image("img/block.png"));
blockImages.put('G', new Image("img/goal.png"));
} catch (SlickException ex) {
ex.printStackTrace();
}
}
public static void render(Graphics g, char key, int posX, int posY)
throws SlickException {
if (SINGLETON.blockImages.containsKey(key)) {
g.drawImage(SINGLETON.blockImages.get(key), getX(posX, posY),
getY(posY));
}
}
private static int getX(int posX, int posY) {
return posX * BLOCK_WIDTH + posY * BLOCK_WIDTH / 2;
}
private static int getY(int posY) {
return posY * BLOCK_WIDTH / 4;
}
}
Layer
Die Klasse Layer vereinigt viele Blöcke zu einer Plattform. Diese sind fest in dem char[][] blocks hinterlegt. Da es sich um eine 2,5D-Applikation handelt, muss beim Zeichnen der Blöcke eine gewisse Reihenfolge eingehalten werden. Es wird von rechts nach links und von oben nach unten gezeichnet:
Neben der strikten Reihenfolge ist auch die Höhe und Breite der Plattform relevant, um diese im Bildschirm zu zentrieren. Zudem braucht die Klasse Layer eine Methode, welche den Typ eines Blocks an einer bestimmten Position zurückgibt, damit überprüft werden kann, ob sich der Spieler noch auf der Plattform befindet. Auch dies lässt sich in wenigen Zeilen realisieren:
public class Layer {
private char[][] blocks = new char[][] {
{ '0', 'B', 'B', 'B', 'B' },
{ '0', 'B', 'B', 'G', '0' },
{ 'B', 'B', 'B', 'B', '0' },
{ '0', 'B', 'B', '0', '0' } };
public void render(Graphics g) throws SlickException {
for (int i = blocks[0].length - 1; i >= 0; i--) {
for (int j = 0; j < blocks.length; j++) {
BlockRenderer.render(g, blocks[j][i], i, j);
}
}
}
public int getWidth() {
return blocks[0].length * BlockRenderer.BLOCK_WIDTH + blocks.length
* (BlockRenderer.BLOCK_WIDTH / 2);
}
public int getHeight() {
return blocks.length * (BlockRenderer.BLOCK_WIDTH / 4);
}
public char getBlock(int posX, int posY) {
if (posX < 0 || posX >= blocks.length || posY < 0
|| posY >= blocks[0].length) {
return '0';
}
return blocks[posX][posY];
}
}
Player
Der Code von dem Spieler wird ein bisschen kryptischer umgesetzt. Das folgende Zustandsdiagramm stellt alle Zustände und Übergänge dar, zwischen denen der Spieler wechseln kann.
Die drei Zustände sind in dem enum State hinterlegt. Abhängig vom Zustand wird das passende Bild gesetzt. Über die move()-Methode lässt sich dieser ändern. Dabei ist die Bewegungsrichtung ausschlaggebend. Anhand dem aktuellen Zustand und der Bewegungsrichtung wird die nächste Position über die Bewegungsmatrix moves ermittelt. Zusätzlich ist es möglich den Spieler mit der resetPosition()-Methode auf eine bestimmte Position zu stellen. Damit geprüft werden kann, wo sich der Spieler momentan befindet, wird die Position in einem int[2] oder int[4] zurückgegeben.
public class Player {
public enum Direction {
UP, DOWN, RIGHT, LEFT
}
public enum State {
STANDING, LYING_HORIZONTAL, LYING_VERTICAL
}
private short[][] moves = {
{ -2, 0, 1, 0, -1, 1, 2, -2 },
{ -1, 0, 1, 0, -2, 2, 1, -1 },
{ -1, 0, 2, 0, -1, 1, 1, -1 }};
private int x, y;
private Map<State, Image> images;
private State state;
public Player() throws SlickException {
images = new HashMap<>();
images.put(STANDING, new Image("img/BlockR.png"));
images.put(LYING_HORIZONTAL, new Image("img/BlockR2.png"));
images.put(LYING_VERTICAL, new Image("img/BlockR3.png"));
}
public void render(Graphics g) throws SlickException {
int width = images.get(STANDING).getWidth();
int blockHeight = (images.get(STANDING).getHeight() - width / 2) / 2;
int imgPosX = y * width + x * width / 2;
int imgPosY = 0;
if (state == STANDING) {
imgPosY = x * width / 4 - blockHeight * 2;
} else if (state == LYING_HORIZONTAL) {
imgPosY = (x - 1) * width / 4 - blockHeight;
} else {
imgPosY = x * width / 4 - blockHeight;
}
g.drawImage(images.get(state), imgPosX, imgPosY);
}
public void move(Direction direction) {
x += moves[state.ordinal()][direction.ordinal() * 2];
y += moves[state.ordinal()][direction.ordinal() * 2 + 1];
updateState(direction == Direction.RIGHT || direction == Direction.LEFT);
}
private void updateState(boolean isMovingHorizontal) {
if (state == STANDING) {
state = isMovingHorizontal ? LYING_HORIZONTAL : LYING_VERTICAL;
} else if ((state == LYING_HORIZONTAL && isMovingHorizontal)
|| (state == LYING_VERTICAL && !isMovingHorizontal)) {
state = STANDING;
}
}
public void resetPosition(int x, int y) {
this.x = x;
this.y = y;
this.state = STANDING;
}
public int[] getPosition() {
if (state == STANDING) {
return new int[] { x, y };
} else if (state == LYING_HORIZONTAL) {
return new int[] { x, y, x - 1, y + 1 };
} else {
return new int[] { x, y, x + 1, y };
}
}
}
Leap Motion – CircleGesture
Zeichnet der Anwender einen Kreis im Uhrzeigersinn, dann soll sich der Block nach rechts bewegen. Links, sofern der Kreis gegen den Uhrzeigersinn gezeichnet wurde. Selbstverständlich steht dazu die Klasse CircleGesture zur Verfügung, welche hier nicht benutzt wird. Ziel ist es eine eigene Geste zu definieren. Dies wird mithilfe des Zonenwechsel-Events des PointListener realisiert. Ein Kreis bildet sich, wenn die vier Zonen Rechtsunten, Rechtsoben, Linksoben und Linksunten nacheinander betreten wurden. Genau genommen umfasst die Logik mehr als nur eine CircleGesture. Das könnte zum Beispiel ein sehr wackliger Kreis, eine Ellipse oder ein Viereck sein. Um die Geste zu verifizieren, wird folgender Code benötigt:
public boolean isCricle() {
List<EnumSet<Zone>> containsZones = new ArrayList<>();
containsZones.add(EnumSet.of(Zone.RIGHT, Zone.DOWN));
containsZones.add(EnumSet.of(Zone.RIGHT, Zone.UP));
containsZones.add(EnumSet.of(Zone.LEFT, Zone.UP));
containsZones.add(EnumSet.of(Zone.LEFT, Zone.DOWN));
for(int i = 0; i < zones.size(); i++) {
for(int j = 0; j < containsZones.size(); j++) {
if(zones.get(i).containsAll(containsZones.get(j))) {
containsZones.remove(j);
break;
}
}
}
return containsZones.size() == 0;
}
Was noch nicht berücksichtig wurde, ist die Bewegungsrichtung. Diese wird ermittelt, indem eine im Uhrzeigersinn sortierte containsZones-Liste angelegt wird. Sofern eine Übereinstimmung festgestellt wurde, wird der Index gespeichert und der Eintrag in der containsZones entfernt. Bewegt sich die Hand im Uhrzeigersinn, dann müsste beim selben Index eine Übereinstimmung festgestellt werden. Ausnahme bildet der vierte Index, nach welchem der erste Index folgt.
Leap Motion – LoopGesture
Die Looping Geste vom Prinzip her genau wie die CircleGesture. Leidglich die Zonen bewegen sich von (Vorne-Unten, Vorne-Oben, Hinten-Oben, Hinten-Unten).
private List<EnumSet<Zone>> zones = new ArrayList<>();
@Override
public void zoneChanged(PointEvent event) {
if(zones.size() == 4) {
zones.remove(0);
}
zones.add(event.getZones());
if(zones.size() == 4) {
List<EnumSet<Zone>> containsZones= new ArrayList<>();
containsZones.add(EnumSet.of(Zone.RIGHT, Zone.DOWN));
containsZones.add(EnumSet.of(Zone.RIGHT, Zone.UP));
containsZones.add(EnumSet.of(Zone.LEFT, Zone.UP));
containsZones.add(EnumSet.of(Zone.LEFT, Zone.DOWN));
List<EnumSet<Zone>> containsZones2 = new ArrayList<>();
containsZones2.add(EnumSet.of(Zone.UP, Zone.FRONT));
containsZones2.add(EnumSet.of(Zone.UP, Zone.BACK));
containsZones2.add(EnumSet.of(Zone.DOWN, Zone.BACK));
containsZones2.add(EnumSet.of(Zone.DOWN, Zone.FRONT));
int circle1 = getCricleType(containsZones);
int circle2 = getCricleType(containsZones2);
if(circle1 != -1) {
zones.clear();
if(circle1 == 0) {
player.move(Direction.RIGHT);
} else {
player.move(Direction.LEFT);
}
} else if(circle2 != -1) {
zones.clear();
if(circle2 == 0) {
player.move(Direction.UP);
} else {
player.move(Direction.DOWN);
}
}
}
}
public int getCricleType(List<EnumSet<Zone>> containsZones) {
int deletedLast = -1;
int direction = -1;
for(int i = 0; i < zones.size(); i++) {
for(int j = 0; j < containsZones.size(); j++) {
if(zones.get(i).containsAll(containsZones.get(j))) {
containsZones.remove(j);
if(deletedLast != -1 && direction == -1) {
if(deletedLast == j || (j == 0 && deletedLast == 4)) {
direction = 0;
} else {
direction = 1;
}
}
deletedLast = j;
break;
}
}
}
return containsZones.size() == 0 ? direction : -1;
}
Fazit
Die eigene CircleGesture ist noch ausbaufähig. Zum Beispiel wird der Kreis im Uhrzeigersinn unterbrochen, wenn man versehentlich von der vorderen in die hintere Zone rutscht. Ohne eine visuelle Orientierung ist es sehr schwer die Geste intuitiv auszuführen. Aus diesem Grund wurde improvisatorisch eine visuelle Anzeige hinzugefügt. Vom Vorteil ist, dass auch undeutliche mit der Hand gezeichnete Kreise und Vierecke erkannt werden. Bei Applikationen mit wenigen ähnlichen Gesten würde das die Fehlertoleranz deutlich erhöhen und somit einen flüssigeren Ablauf garantieren.