JavaScript-Tutorium:Physics

aus GlossarWiki, der Glossar-Datenbank der Fachhochschule Augsburg

Physics

Unter Physics versteht man in der Informatik die Simulation physikalischer Gesetze wie Geschwindigkeit und Erdanziehung. In der Spieleprogrammierung werden solche Simulationen verwendet um gewisse Spielmechaniken natürlicher wirken zu lassen. Dieses Tutorium erklärt simple nützliche Formeln und Berechnungen für die Entwicklung von Simulationen in JavaScript.


Allgemeines

Zugehörigkeit

Bei einer Modellierung nach dem MVC-Paradigma stellt sich die Frage wo der Code für physikalische Simulationen hingehört. Versteht man das Model so dass es neben den Daten ebenso die Logik enthält so schliesst dies den Physics-Code eindeutig ein. Die Gegebenheit dass ein Spiel physikalischen Gesetzen folgt kann also wie jede andere Regel des Spiels verstanden werden.


Abstrakte Einheiten

Obwohl im Model nicht mit Pixeln gearbeitet werden soll spricht nichts dagegen abstrakte numerische Einheiten zu verwenden. Gegebenenfalls ist es durchaus sinnvoll ein Koordinatensystem einzuführen und Spielelemente mit Dimensionen zu versehen. Wichtig ist dabei nur dass diese Einheiten keinen direkten Bezug zu Pixel haben selbst wenn der Umrechungsfaktor eins beträgt.

var Player = function(x, y, width, height) {
  var self = this;
  self.x = x;
  self.y = y;
  self.width = width;
  self.height = height;
};

var renderPlayer = function(player, scaleFactor) {
  var div = document.createElement('div');
  div.style.position = 'absolute';
  div.style.backgroundColor = '#F00';
  div.style.width = player.width * scaleFactor + 'px';
  div.style.height = player.height * scaleFactor + 'px';
  div.style.left = player.x * scaleFactor  + 'px';
  div.style.top = player.y * scaleFactor+ 'px';
  document.querySelector('body').appendChild(div);
};

var player1 = new Player(10, 10, 10, 10);
renderPlayer(player1, 5);


Selbst Spiele wie "Tic Tac Toe" arbeiten mit Koordinatensystemen, oftmals aber als multidimensionales Array dargestellt,

var TicTacToe = function() {
  var self = this;
  self._spielfeld = [
    ['', '', ''],
    ['', '', ''],
    ['', '', '']
  ];
};

Welt und Elemente

Innerhalb einer physikalischen Simulation ist es sinnvoll das Modell in eine Welt und deren enthaltenen Elemente aufzuteilen. Dabei kapseön Elemente ihre physikalischen Eigenschaften während die Welt regelmäßig ihre Berechnungen auf diese anwendet. Dies wird durchgeführt mithilfe von eigenständigen Timern (z.B. durch setTimeout()) ähnlich wie beim Rendering von Objekten.

var Element = function(x, y, width, height) {
  var self = this;
  self.x = x;
  self.y = y;
  self.width = width;
  self.height = height;
};

var World = function(width, height) {

  var self = this;
  self._currentTimeout = null;
  self._elements = [];

  self.addElement = function(element) {
    self._elements.push(element);
  };

  self._updateElements = function() {
    for (var i = 0; i < self._elements.length; i++) {
      // TODO: Your code goes here
    }
  };

  self._updateElementsRepeatedly = function() {
    self._updateElements();
    self._currentTimeout = setTimeout(self._updateElementsRepeatedly, 15);
  };
  self._updateElementsRepeatedly();

};

Ortsänderungen

Nahezu jedes Spiel welches eine physikalischen Simulation beinhaltet arbeitet mit der Veränderung vom Ort einzelner Elemente.

Geschwindigkeit

Eine simple Simulation von Geschwindigkeit kann bereits erreicht werden indem man die Koordinaten von Objekten verändert.

self._updateElements = function() {
  for (var i = 0; i < self._elements.length; i++) {
    self._elements[i].x += 1;
    self._elements[i].y += 1;
  }
};

Mithilfe von Geschwindigkeitsattributen können dann Objekte selbst entscheiden wie sich ihre Koordinaten verändern sollen. Anmerkung: Es ist Konvention Geschwindigkeitsattribute mit einem "v" zu versehen, welches für "Velocity" steht.

var Element = function(x, y) {
  var self = this;
  self.x = x;
  self.y = y;
  self.vx = 0;
  self.vy = 0;
}

var element = new Element(0, 0);
element.vx = 1;
element.vy = 1;

self._updateElements = function() {
  for (var i = 0; i < self._elements.length; i++) {
    self._elements[i].x += self._elements[i].vx;
    self._elements[i].y += self._elements[i].vy;
  }
};

Verknüpft man die Steuerung der Geschwindigkeitswerte nun mit dem Keyboard erreicht man eine einfache Spielersteuerung.

var Controller = function(element) {
  document.addEventLister('keydown', function(event) {
    if (event.keyCode == 37) {
      element.vx = -0.5;
    }
    if (event.keyCode == 39) {
      element.vx = 0.5;
    }
  });
  document.addEventLister('keyup', function() {
    element.vx = 0;
  });
};


Beschleunigung

Neben der Geschwindigkeit ist die Beschleunigung ein weiterer wichtiger Faktor für physikalische Simulationen in Spielen. Beschleunigung verhält sich zu Geschwindigkeit wie Geschwindigkeit zu Ort, also eine konstante Änderung der Geschwindigkeit.

var Element = function(x, y) {
  var self = this;
  self.x = x;
  self.y = y;
  self.vx = 0;
  self.vy = 0;
  self.ax = 0;
  self.ay = 0;
}

var element = new Element(0, 0);
element.vx = 1;
element.ay = .1;

self._updateElements = function() {
  var element = null;
  for (var i = 0; i < self._elements.length; i++) {
    var element = self._elements[i];
    element.vx += element.ax;
    element.vy += element.ay;
    element.x += element.vx;
    element.y += element.vy;
  }
};

Erdanziehung

Ein häufiger Einsatzzweck der Beschleunigung ist die vereinfachte Simulation einer global angewendeten Erdanziehungskraft.

self._ay = 0.1;
self._updateElements = function() {
  var element = null;
  for (var i = 0; i < self._elements.length; i++) {
    element = self._elements[i];
    element.vx += element.ax;
    element.vy += element.ay + self._ay;
    element.y += element.vy;
    element.y += element.vy;
  }
};


Unbewegliche Objekte

Häufig gibt es in Simulationen auch unbewegliche Objekte welche nicht von Ortsänderungen betroffen sein sollen. Diese können einfach mit einer boolschen Eigenschaft versehen werden welche dann in der Welt abgefragt wird.

self._updateElements = function() {
  var element = null;
  for (var i = 0; i < self._elements.length; i++) {
    element = self._elements[i];
    if (!element.isImmovable) {
      element.vx += element.ax;
      element.vy += element.ay + self._gy;
      element.y += element.vy;
      element.y += element.vy;
    }
  }
};


Kollisionen

In vielen Spielen ist es das Ziel mit dem Spieler bestimmte Elemente zu treffen und anderen Elementen wiederum auszuweichen. Diese Gegebenheit lässt sich in der Regel mittels vereinfachter Kollisionserkennung und Kollisionauflösung umsetzen.


Begrenzungen

Ein spezielles Element mit dem der Spieler in fast jedem Spiel kollideren kann ist die Welt selbst bzw. deren Begrenzung. Da es sich normalerwise um eine rechteckige Begrenzung handelt kann leicht überprüft werden ob eine Kollision stattfindet. Darf der Spieler die Welt nicht verlassen so können seine physikalischen Attribute einfach entsprechend angepasst werden.


self._keepElementsInsideWorld = function() {
  var element = null;
  for (var i = 0; i < self._elements.length; i++) {
    element = self._elements[i];
    if (element.x < 0) {
      element.x = 0;
      element.vx = element.ax = 0;
    }
    if (element.x + element.width > self._width) {
      element.x = self._width - element.width;
      element.vx = element.ax = 0;
    }
    if (element.y < 0) {
      element.y = 0;
      element.vy = element.ay = 0;
    }
    if (element.y + element.height > self._height) {
      element.y = self._height - element.height;
      element.vy = element.ay = 0;
    }
  }
};

Tipp: Man sollte ebenso darauf achten die x- und y-Position des mit der Welt kollidierenden Elements anzupassen.


Kollisionserkennung

Eine einfache Weise um Kollisionen zwischen beliebigen Objekten erkennen zu können ist die Verwendung von "Bounding Boxes". Dabei berechnet man für Objekte beliebiger Form die kleinst möglichen Rechtecke und verwendet diese zur Kollisionserkennung. Möchte man diese nicht extra berechnen interpretiert man einfach alle Elemente innerhalb einer Simulation als Rechtecke. Die Berechnung um festzustellen ob sich zwei nicht gedrehte Rechtecke überschneiden gestaltet sich als sehr einfach.

self._areElementsColliding = function(a, b) {
  if ((a.x > b.x + b.width) || (b.x > a.x + a.width)) {
    return false;
  }
  else if ((a.y > b.y + b.height) || (b.y > a.y + a.height)) {
    return false;
  }
  return true;
};

Ebenso ist die Kollisionserkennung für zwei beliebig große Kreisformen relativ einfach zu berechnen. Kombiniert man jedoch Kreise und Rechtecke erhöht sich die Komplexität alleine aufgrund der Fallunterscheidungen. Deshalb ist es empfehlenswert Kreise ebenfalls als Rechtecke aufzufassen solange keine exakte Erkennung notwendig ist.

Für die Kollisionserkennung aller Elemente innerhalb einer Welt kann man eine verschachtelte for-Schleife nutzen.

self._detectCollisions = function() {
  var a = null, b = null;
  for (var i = 0; i < self._elements.length - 1; i++) {
    a = self._elements[i];
    for (var j = i + 1; j < self._elements.length; j++) {
      b = self._elements[i];
      // TODO: Your code goes here
    }
  }
};

Auf diese Weise wird jede mögliche Zweierkombination von Elementen garantiert auch nur einmal durchlaufen.

Kollisionstiefe

Um zu wissen aus welcher Richtung die Elemente sich angenähert haben kann die Kollisionstiefe der Achsen berechnet werden.

self._getCollisionDepth = function(a, b) {
  var xValues = [a.x, a.x + a.width, b.x, b.x + b.width];
  var yValues = [a.y, a.y + a.height, b.y, b.y + b.height];
  var sort = function(firstValue, secondValue) {
    return firstValue - secondValue;
  };
  xValues.sort(sort);
  yValues.sort(sort);
  return {
    x: xValues[2] - xValues[1],
    y: yValues[2] - yValues[1]
  };
};

Beträgt die x-Tiefe einen niedrigeren Wert als die y-Tiefe nähern sich die Elemente horizontal an, im anderen Fall vertikal. Bei einer horizontalen Annäherung zweier Elemente bezeichnet man die y-Achse als die Kollisionsachse, andernfalls die x-Achse.

var collisionDepth = self._getCollisionDepth(a, b);
var collisionAxis = collisionDepth.x < collisionDepth.y ? 'y' : 'x';


Reaktion auf Kollision

Abhängig davon welche Elemente wie kollidieren und welche Spielregeln existieren kann es unterschiedliche Reaktionen geben.


Umkehrung von Geschwindigkeit

Bei beweglichen Elementen in einer Kollision ist es sinnvoll die Geschwindigkeitsanteile entlang der Kollisionsachse umzukehren.

var collisionDepth = self._getCollisionDepth(a, b);
var collisionDepth = self._getCollisionDepth(a, b);
var collisionAxis = collisionDepth.x > collisionDepth.y ? 'y' : 'x';
if (collisionAxis == 'y') {
  a.vx *= -1;
  b.vx *= -1;
}
else {
  a.vy *= -1;
  b.vy *= -1;
}

Beispiel: Ein Ball fliegt mit einer Geschwindigkeit vx von 2 und vy von 1, also nach rechts oben, gegen ein unbewegliches Hindernis. Für die Kollisionstiefe ergibt sich im x-Anteil einen größeren Wert als für y, dementsprechend ist y die Kollisionsachse. Der x-Anteil der Geschwindigkeit vom Ball wird umgekehrt auf -2 sodass der Ball sich weiter nach links oben bewegt. Die Geschwindigkeit des Hindernisses braucht nicht umgekehrt werden, da es sich um ein unbewegliches Element handelt.


Auflösung von Kollision

Die Umkehrung der Geschwindigkeitsanteile führt in der Regel dazu dass sich die Kollision bald wieder von selbst auflöst. Sollen die Elemente jedoch angehalten werden, muss die entstandene Überschneidung der Kollision rückgängig gemacht werden. Dazu werden die Elemente je nach ihrer Geschwindigkeit und Beweglichkeit um Anteile der Kollisionstiefe auseinander gezogen.

var collisionDepth = self._getCollisionDepth(a, b);
var collisionDepth = self._getCollisionDepth(a, b);
var collisionAxis = collisionDepth.x > collisionDepth.y ? 'y' : 'x';
if (collisionAxis == 'y') {
  if (a.vx > 0) {
    a.x -= (collisionDepth.x / 2);
    b.x += (collisionDepth.x / 2);
  }
  else {
    a.x += (collisionDepth.x / 2);
    b.x -= (collisionDepth.x / 2);
  }
}
else {
  if (a.vy > 0) {
    a.y -= (collisionDepth.y / 2);
    b.y += (collisionDepth.y / 2);
  }
  else {
    a.y += (collisionDepth.y / 2);
    b.y -= (collisionDepth.y / 2);
  }
}

Gibt es nur ein bewegliches Element wird nur dieses um die Kollisionstiefe entgegen der Geschwindigkeitsrichtung zurückbewegt.

var collisionDepth = self._getCollisionDepth(a, b);
var collisionDepth = self._getCollisionDepth(a, b);
var collisionAxis = collisionDepth.x > collisionDepth.y ? 'y' : 'x';
if (collisionAxis == 'y') {
  if (a.isImmovable) {
    b.x += b.vx > 0 ? -collisionDepth.x : collisionDepth.x;
  }
  else if (b.isImmovable) {
    a.x += a.vx > 0 ? -collisionDepth.x : collisionDepth.x;
  }
}
else {
  if (a.isImmovable) {
    b.y += b.vy > 0 ? -collisionDepth.y : collisionDepth.y;
  }
  else if (b.isImmovable) {
    a.y += a.vy > 0 ? -collisionDepth.y : collisionDepth.y;
  }
}

Ereignisse

Unabhgängig davon wie und ob auf Kollisionen reagiert wird ist es sinnvoll bei jeder Kollision ein Ereignis zu erzeugen. Auf diese Weise können anderen Programmkomponenten diese Information zu verarbeiten und z.B. visuell darzustellen.

Tipp: Nutzen Sie den EventDispatcher um Ereignisse zu erzeugen wenn Kollisionen auftreten.