Ani.js - schlanke Animationsbiliothek aus JavaScript und Canvas

Ani.js - schlanke Animationsbiliothek aus JavaScript und Canvas
Von Lars Ebert am 06.08.12, 11:00
Kategorien: JavaScript, Programmieren and Tutorials

Letzte Woche gab es hier aus Zeitmangel nur einen kleinen animierten Ninja als Demo meiner Animationsbibliothek ani.js zu sehen. Diese Woche will ich nun ein kleines Tutorial nachliefern, mit dem du selbst die Animationsbibliothek ani.js nutzen kannst.

Als erstes solltest du jQuery und Ani.js downloaden und die beiden Dateien in deine HTML-Datei einbinden.

In deinem HTML-Dokument brauchst du nur ein (Div-)Element anlegen, in welchem sich die Animation befinden soll.

Nun kannst du mit der Animation beginnen! Lege dazu eine neue JS-Datei an und binde sie nach jQuery und Ani.js ein! In dieser Datei wird später der Code für die Animation stehen.

Grundlagen von Ani.js

Ani.js ist eine objektorientierte Bibliothek (wenn auch eine sehr locker geschriebene). Es gibt drei wichtige Objekt-Typen.

Scene()

Das Scene-Objekt ist am ehesten mit der Leinwand eines Films gleichzusetzen, sie beinhaltet später alle Objekte, die auf der Leinwand animiert werden. Die wichtigsten Methoden:

  • setElement(element)
  • loadImages(images)
  • loadSounds(sounds)
  • playSound(sound)
  • init()
  • update()
  • addNode(name, node)
  • addAnimation(name, node)
  • removeNode(name)
  • removeAnimation(name)

Was genau diese Methoden machen, werde ich gleich noch erläutern.

Node()

Ani.js speichert alle Elemente auf der Leinwand als Nodes, wobei die Scene den Wurzelknoten darstellt. So können auch komplexe Figuren mit vielen Einzelgrafiken gespeichert und animiert werden. Beispielsweise könnte man jede Person auf der Leinwand als einen Node speichern und jedes Körperteil wiederum als Node innerhalb des Personen-Nodes speichern. Wenn man dann den Personen-Node bewegt, bewegen sich die Körperteile automatisch mit.

Die Knotenstruktur des Ninjas
Zur Verdeutlichung hier einmal die Struktur meiner Ninja-Animation.

Jeder Node (einschließlich der Scene) kann übrigens beliebig viele Child-Nodes haben, aber nur einen Parent-Node (wobei die Scene natürlich keinen Parent-Node hat).

Die wichtigsten Methoden:

  • addNode(name, node)
  • addAnimation(name, node)
  • removeNode(name)
  • removeAnimation(name)

Animation()

Jeder Scene und jedem Node können beliebig viele Animationen zugeordnet werden. Die genaue Funktionsweise einer Animation werde ich weiter unten noch erläutern, aber kurz gesagt ist jede Animation ein eigener, in sich geschlossener Bewegungsablauf. Bei meinem Ninja ist zum Beispiel das Atmen eine Animation, das Blinzeln ist eine Animation und so weiter.

Die wichtigsten Methoden:

  • start()
  • stop()

Nun wollen wir aber endlich anfangen, etwas zu animieren!

Darstellung des Charakters

Ich gehe zunächst davon aus, dass alle notwendigen Bilder bereits vorliegen. Momentan unterstützt Ani.js nur PNG-Dateien und diese müssen zwingend im Ordner images (relativ zur HTML-Datei, in der die Animation abläuft) liegen. Du kannst dir gerne die Grafiken aus meiner Demo herunterladen, du kannst aber auch gerne eigene Bilder erstellen.

Zunächst müssen wir eine neue Scene anlegen und dieser das Element zuweisen, in welchem die Animation erscheinen soll.


var scene = new Scene(900, 500);

$(document).ready(function() {
	scene.setElement($('#canvas'));
});

Ich habe die Scene außerhalb der Funktion angelegt, damit sie von überall (und vor allem jederzeit) angesprochen werden kann. Die beiden Parameter geben die Breite und Höhe der Leinwand an.

Sobald die Seite vollständig aufgebaut worden ist, können wir der Scene das gewünschte Element zuweisen. In diesem Element werden später automatisch ein oder meherere Canvases eingefügt.

Als nächstes lassen wir die Scene alle benötigten Bilder laden:


var scene = new Scene(900, 500);

$(document).ready(function() {
	scene.setElement($('#canvas'));
	
	scene.loadImages(['body', 'face', 'head', 'knife', 'limb', 'nunchuk', 'star', 'stick', 'face-bloody', 'head-bloody', 'limb-bloody', 'stick-bloody']);
});

Der Methode loadImages() können wir als Parameter einfach ein Array mit Bildnamen übergeben. Die Scene läd dann automatisch das entsprechende Bild aus dem Ordner images. Unter dem Namen sind die Bilder später auch auswählbar.

Sobald alle Bilder geladen wurden, ruft die Scene die Methode init() auf. Diese Methode ist absolut leer, sie ist (so wie die Methode update()) nur dafür da, von dir überschrieben zu werden. Und genau das tun wir jetzt!


scene.init = function() {
	for(var i = 0; i <= 7; i++) {
		scene.newCanvas(i);
	}
}

In der For-Schleife legen wir als erstes 7 Ebenen an, diese Ebenen funktionieren genau so wie in Photoshop, du kannst später bei jedem Node eine Ebene angeben, auf der dieser Node abgebildet wird.

Nun können wir innerhalb der Scene beliebig viele Nodes anlegen. Ich habe in meinem Beispiel nur einen Node angelegt, nämlich den Ninja-Node. Neue Nodes können immer über die Methode addNode(name, node) zu einem Node zugeordnet werden. Der Parameter name ist der Name, unter dem dieser Node später ansprechbar ist und der Parameter node ist ein Objekt des Typs Node, also der Node, den wir hinzufügen wollen.

Einen neuen Node können wir ganz einfach anlegen, indem wir die Funktion Node(options) aufrufen, welche als Parameter ein Objekt mit den Optionen für den neuen Node erwartet. Hier die Standard-Optionen, welche gesetzt werden, wenn die entsprechende Option nicht gesetzt wird:


{
	layer: 1, //definiert, auf welcher Ebene der Node angezeigt werden soll
	x: 0, //gibt die X-Position des Nodes relativ zum Parent-Node an
	y: 0, //gibt die Y-Position des Nodes relativ zum Parent-Node an
	rotation: {
		x: 0, //X-Position des Rotations-Zentrums relativ zum Node
		y: 0, //Y-Position des Rotations-Zentrums relativ zum Node
		angle: 0 //Betrag der Drehung, wobei ein Wert von 1 einer Drehung von 360° entspricht
	},
	func: 0, //Funktion zum Rendern des Nodes, erhält drei Parameter: die X- und Y-Position des Parent-Nodes (relativ zur Scene) und eine Referenz auf den aktuellen Node
	image: 0, //Name einer bereits geladenen Bilddatei, wird beim Rendern an der Stelle X:Y angezeigt
	animations: {}, //Objekt mit Animationen
	nodes: {}, //Objekt mit Child-Nodes
	additional: {} //Zusätzliche Variablen für den Node
}

Beispielsweise wird bei mir der Ninja-Node so zur Scene hinzugefügt:


scene.addNode('ninja', new Node({
	x: scene.width/2, 
	y: scene.height*0.9,
	rotation: {
		x: 0,
		y: -90
	}
});

In Zeile zwei und drei lege ich fest, dass der Ninja sich in der Mitte des Bildes und zehn Prozent über dem unteren Rand der Leinwand befindet, danach definiere ich noch, dass sich der Rotationspunkt 90 Pixel über der Position des Ninjas befindet (dies ist bei mir etwa der Mittel-/Schwerpunkt des Ninjas).

Eine Methode, einen neuen Node anzulegen, kennst du jetzt bereits, nämlich über die Methode addNode. Allerdings gibt es noch eine zweite Möglichkeit, einen Child-Node hinzuzufügen, und zwar, indem man ihn direkt über die Optionen an den Node übergibt. Dazu erweitere ich nun das vorherige Beispiel etwas:


scene.addNode('ninja', new Node({
	x: scene.width/2, 
	y: scene.height*0.9,
	rotation: {
		x: 0,
		y: -90
	},
	nodes: {
		body: new Node({
			layer: 3,
			x: -58.5,
			y: -115,
			rotation: {
				x: 117/2,
				y: -10
			},
			image: 'body'
		})
	}
});

Wie du siehst, kann ich direkt über die Option nodes im Konstruktor eines Nodes weitere Child-Nodes hinzufügen. So kann ich alle Elemente des Ninjas direkt ineinander verschachteln.

Hier siehst du übrigens auch, wie man ein Bild angibt, du brauchst nur den Namen des Bildes angeben und es wird angezeigt.

Zur Demonstration füge ich noch einen weiteren Node hinzu:


scene.addNode('ninja', new Node({
	x: scene.width/2, 
	y: scene.height*0.9,
	rotation: {
		x: 0,
		y: -90
	},
	nodes: {
		shadow: new Node({
			layer: 0,
			func: function(x, y, thisNode) {
				drawEllipse(x + thisNode.x, y + thisNode.y, thisNode.width, 10, 'rgba(0, 0, 0, 0.5)', thisNode.scene.contexts[thisNode.layer]);
			},
			additional: {
				width: 230
			}
		}),
		body: new Node({
			layer: 3,
			x: -58.5,
			y: -115,
			rotation: {
				x: 117/2,
				y: -10
			},
			image: 'body'
		})
	}
});

Neben dem Ninja- und Body-Node füge ich jetzt noch einen Shadow-Node hinzu, der den Schatten abbilden soll. Da ich weder X- noch Y-Position angegeben habe, werden x und y automatisch 0 und der Schatten wird an der gleichen Position wie sein Parent-Node abgebildet. Außerdem soll hier kein Bild abgebildet werden, sondern eine Funktion aufgerufen werden, die wir über func definieren.

Zu bemerken ist hier, dass wir über den dritten Parameter thisNode auf alle Eigenschaften des Nodes zugreifen können, außerdem können wir über die Parameter x und y auf die Position des Parent-Node zugreifen. Zur Berechnung der absoluten Position des Nodes können wir deshalb einfach x + thisNode.x bzw. y + thisNode.y benutzen.

Die Funktion drawEllipse ist übrigens auch Bestandteil von Ani.js, sie erwartet als Parameter die Koordinaten des Mittelpunktes der Ellipse, die Breite und Höhe der Ellipse, die Füllfarbe der Ellipse (als String, z.B. "#ff0000") und den Context, auf dem die Ellipse gezeichnet werden soll.


function(x, y, thisNode) {
	drawEllipse(x + thisNode.x, y + thisNode.y, thisNode.width, 10, 'rgba(0, 0, 0, 0.5)', thisNode.scene.contexts[thisNode.layer]);
}

Die Funktion des Shadow-Nodes zeichnet also eine Ellipse mit dem Mittelpunkt an der Position des Nodes. Mit thisNode.scene.contexts[thisNode.layer] können wir auf den Context, also den Layer des Nodes zugreifen.

Wichtig ist hier auch, dass wir mit thisNode.width auf die Eigenschaft width, die wir vorher über die Option additional definiert haben, zugreifen können. Dies wird später wichtig, wenn wir den Schatten animieren wollen.

Ich habe in dieser Form auch alle anderen Elemente des Ninjas angelegt:


var scene = new Scene(900, 500);

$(document).ready(function() {
	scene.setElement($('#canvas'));
	
	scene.loadImages(['body', 'face', 'head', 'knife', 'limb', 'nunchuk', 'star', 'stick', 'face-bloody', 'head-bloody', 'limb-bloody', 'stick-bloody']);
	
	scene.init = function() {
		for(var i = 0; i <= 7; i++) {
			scene.newCanvas(i);
		}
		
		scene.addNode('ninja', new Node({
			x: scene.width/2, 
			y: scene.height*0.9,
			rotation: {
				x: 0,
				y: -90
			},
			nodes: {
				shadow: new Node({
					layer: 0,
					func: function(x, y, thisNode) {
						drawEllipse(x + thisNode.x, y + thisNode.y, thisNode.width, 10, 'rgba(0, 0, 0, 0.5)', thisNode.scene.contexts[thisNode.layer]);
					},
					additional: {
						width: 230
					}
				}),
				body: new Node({
					layer: 3,
					x: -58.5,
					y: -115,
					rotation: {
						x: 117/2,
						y: -10
					},
					image: 'body',
					nodes: {
						head: new Node({
							layer: 4,
							x: -28.5,
							y: -135,
							rotation: {
								x: 176/2,
								y: 174/2
							},
							image: 'head',
							nodes: {
								face: new Node({
									layer: 5,
									x: 27,
									y: 90,
									rotation: {
										x: 122/2,
										y: 174/2 - 90
									},
									image: 'face',
									nodes: {
										leftEye: new Node({
											layer: 6,
											x: 66-25,
											y: 25,
											func: function(x, y, thisNode) {
												drawEllipse(x + thisNode.x, y + thisNode.y, 9, thisNode.height, "#000000", thisNode.scene.contexts[thisNode.layer]);
											},
											additional: {
												height: 10
											}
										}),
										rightEye: new Node({
											layer: 6,
											x: 66+25-8,
											y: 25,
											func: function(x, y, thisNode) {
												drawEllipse(x + thisNode.x, y + thisNode.y, 9, thisNode.height, "#000000", thisNode.scene.contexts[thisNode.layer]);
											},
											additional: {
												height: 10
											}
										})
									}
								})
							}
						}),
						leftLeg: new Node({
							layer: 1,
							x: 79.5,
							y: 48,
							rotation: {
								x: 19,
								y: 19
							},
							image: 'limb'
						}),
						rightLeg: new Node({
							layer: 1,
							x: -0.5,
							y: 48,
							rotation: {
								x: 19,
								y: 19
							},
							image: 'limb'
						}),
						leftArm: new Node({
							layer: 1,
							x: 100,
							y: 0,
							rotation: {
								x: 19,
								y: 19,
								angle: -1/8
							},
							image: 'limb'
						}),
						rightArm: new Node({
							layer: 1,
							x: -16,
							y: 0,
							rotation: {
								x: 19,
								y: 19,
								angle: 1/8
							},
							image: 'limb'
						})
					}
				})
			}
		}));
	}
});
Ein Ninja!
Wenn du bis hier in alles richtig gemacht hast, steht ein Ninja vor dir!

Ich hoffe, dass das Prinzip von Nodes bis hier hin klar geworden ist, denn jetzt werde ich endlich die Animationen vorstellen!

Bring Bewegung ins Spiel

So wie wir jedem Node beliebig viele Child-Nodes zuweisen können, können wir auch jedem Node beliebig viele Animationen zuweisen. Genau wie Nodes können wir Animationen über die Methode addAnimation oder direkt im Node-Konstruktor über die Option animations anlegen.

Schauen wir uns erst einmal ein Beispiel an, nämlich das Atmen des Ninjas. Um ein Atmen zu simulieren, werden wir den Kopf und die Arme des Ninjas periodisch hoch und runter bewegen, sodass es so wirkt, als bäume sich sein Brustkorb beim Atmen auf.


scene.addNode('ninja', new Node({
	x: scene.width/2, 
	y: scene.height*0.9,
	rotation: {
		x: 0,
		y: -90
	},
	animations: {
		breath: new Animation({
			modifier: function(scene, node, amount, frames) {
				amount /= frames;
				
				node.nodes['body'].nodes['head'].y += amount;
				node.nodes['body'].nodes['leftArm'].y += amount;
				node.nodes['body'].nodes['rightArm'].y += amount;
				node.nodes['shadow'].width += amount*2;
			}, 
			movements: [
				{
					movements: 3,
					duration: 700
				},
				{
					movements: -6,
					duration: 1400
				},
				{
					movements: 3,
					duration: 700
				}
			],
			start: true,
			loop: true
		})
	}
});

Hier legen wir die Animation direkt über die Option animation fest. An welchen Node man die Animation anhängt, ist Geschmackssache. Dabei solltest du jedoch beachten, dass die Animation immer nur Zugriff auf den Node hat, an dem sie hängt, und auf alle seine Child-Nodes.

Schauen wir uns einmal den Konstruktor der Animation an. Sie erwartet wieder als Parameter ein Objekt mit Optionen, aber bei Animationen gibt es nur vier Optionen. Die wichtigsten Optionen sind modifier und movements. Hier wird es jetzt etwas kompliziert, also gut aufpassen!

Über movements übergibst du ein Array mit beliebig vielen Bewegungs-Teilen. Diese Bewegungen werden nacheinander der Reihe nach ausgeführt. Jede Bewegung ist ein Objekt mit zwei Eigenschaften: movements und duration. Duration gibt schlicht und einfach die Länge des Bewegungsschritt in Millisekunden an. Über Movements kannst du entweder einen einzelnen Zahlenwert oder ein Array mit mehreren Zahlenwerten angeben. Innerhalb einer Animation sollte aber die Anzahl der Zahlenwerte bei jedem Bewegungs-Schritt gleich sein.

Diese Zahlenwerte steuern die Animation, in unserem Beispiel bewegen sich Kopf etc. erst um drei Längeneinheiten nach unten, dann um sechs Längeneinheiten nach oben und dann wieder drei Längeneinheiten nach unten, sodass wir wieder da landen, wo wir begonnen haben.

Warum ich »Längeneinheit« sage und nicht z.B. »Pixel«? Ganz einfach weil alleine durch diese Zahlenwerte noch nicht festgelegt ist, wie sich der Kopf bewegt, in welche Richtung oder wie weit. Allein durch die Definition der Bewegungsschritte bewegt sich noch gar nix. Dafür ist nämlich die Funktion modifier, die wir der Animation übergeben, zuständig.

Eine Animation läuft so ab: Pro Frame wird die Funktion modifier einmal aufgerufen, als Parameter erhält sie die Scene, den aktuellen Node und die Zahlenwerte, die wir bei der aktuellen Bewegung definiert haben. Zusätzlich wird der Funktion noch übergeben, auf wie viele Frames sich die aktuelle Bewegung aufteilt.


function(scene, node, amount, frames) {
	amount /= frames;
	
	node.nodes['body'].nodes['head'].y += amount;
	node.nodes['body'].nodes['leftArm'].y += amount;
	node.nodes['body'].nodes['rightArm'].y += amount;
	node.nodes['shadow'].width += amount*2;
}

Schauen wir uns jetzt die Funktion noch einmal genau an. Im ersten Bewegungsschritt wird ihr ein Wert von 3 übergeben. Als nächstes rechnen wir aus diesem Wert und der Anzahl der Frames aus, wie viel sich alles pro Frame bewegen muss. Anschließend kümmern wir uns darum, dass auch etwas von der Animation zu sehen ist. In diesem Beispiel modifizieren wir die Nodes head, leftArm, rightArm und shadow. Hier siehst du auch, wie wir auf Child-Nodes zugreifen können, wir sprechen sie einfach über den ihnen zugewiesenen Namen an.

Die Funktion sorgt also dafür, dass sich Kopf und Arme innerhalb des Bewegungsschritts um den gewünschten Betrag bewegen. Deshalb habe ich übrigens auch vorhin »Längeneinheit« gesagt. Denn hier könnten wir jetzt genau so gut den Zahlenwert immer mit 200 multiplizieren und so erreichen, dass sich die Körperteile erst 600 Pixel nach unten, dann 1200 Pixel nach oben und anschließend wieder 600 Pixel nach unten bewegen.

Kurz gesagt definieren wir also über movements, in welche Richtung die Animation abläuft und wie schnell und über modifier sorgen wir dafür, dass diese Informationen in eine Bewegung umgesetzt werden.

Der Konstruktor der Animation hat noch zwei weitere Optionen, nämlich start und loop. Start gibt hier an, ob die Animation sofort starten soll und loop definiert, ob die Animation in einer Schleife abläuft. In diesem Fall ist beides wünschenswert, deshalb setzen wir beides auf true, Standardwert ist allerdings false.

Als nächstes werde ich noch zur Verdeutlichung die Animation für das Winken erläutern.


wave: new Animation({
	modifier: function(scene, node, amount, frames) {
		amount /= frames;
		
		node.nodes['body'].nodes['leftArm'].rotation.angle += amount;
	}, 
	movements: [
		{
			movements: -3/16,
			duration: 400
		},
		{
			movements: 1/32,
			duration: 100
		},
		{
			movements: -1/32,
			duration: 100
		},
		{
			movements: 1/32,
			duration: 100
		},
		{
			movements: -1/32,
			duration: 100
		},
		{
			movements: 1/32,
			duration: 100
		},
		{
			movements: -1/32,
			duration: 100
		},
		{
			movements: 3/16,
			duration: 400
		}
	]
})

In dieser Animation definieren wir den modifier so, dass der linke Arm um den übergebenen Wert dreht. Praktisch ist, dass ich bei der Definition des Arms bereits den Drehpunkt in die Schulter des Ninjas gelegt habe.

Movements definiert diesmal, dass zunächst der Arm gehoben wird, dann gewunken wird und am Schluss der Arm wieder gesenkt wird.

Diesmal habe ich start und loop nicht gesetzt, also läuft die Animation erst nach manuellem Starten ab und wird auch nicht wiederholt.

Nun müssen wir die Animation noch manuell anstoßen, dies können wir mit folgendem Code zum Beispiel beim Klick auf einen Button erledigen:


	$('#wave').click(function() {
		scene.nodes['ninja'].animations['wave'].start();
	});

Ich hoffe, das zwei Beispiele ausreichen, um die Funktionsweise von Animationen verständlich zu machen. Schau dir doch einfach mal den Code der Demo an und versuch alles zu verstehen.

Danach solltest du in der Lage sein, eigene Animationen zu schreiben.

Ton

Fast hätte ich vergessen, das Abspielen von Tönen zu erläutern. Die Sound-Dateien sollten im Ordner sounds liegen. Wichtig ist, dass sie jeweils einmal als MP3 und einmal als OGG vorliegen, aber mit dem gleichen Dateinamen (bis auf die Endung).

Bevor wir die Sounds verwenden können müssen wir sie, genau wie die Bilder, im Voraus laden.


scene.loadImages(['body', 'face', 'head', 'knife', 'limb', 'nunchuk', 'star', 'stick', 'face-bloody', 'head-bloody', 'limb-bloody', 'stick-bloody']);
scene.loadSounds(['throw1', 'throw2', 'rotate', 'rotate2', 'stick', 'fall', 'kungfu']);

Diese Sounds können wir nun jederzeit abspielen. Übrigens kann man in das Array mit Bewegungen, welches wir einer Animation übergeben, auch jederzeit eine Funktion einwerfen, die dann zwischen zwei Bewegungen ausgeführt wird:


{
	movements: [0, 1],
	duration: 700
},
{
	movements: [0, -1],
	duration: 0
},
function(scene, node) {
	scene.playSound('fall');
},
{
	movements: [-1, 0],
	duration: 200
}

Solch einer Funktion wird stets die Scene und der Node übergeben. In dieser Funktion spielen wir zum Beispiel den Sound fall ab.

Fazit

Ich muss zugeben, ich bin schon ein wenig stolz auf Ani.js. Ich finde die Bibliothek sehr nützlich und es macht einfach Spaß, mit ihr zu arbeiten. Ich hoffe, du kannst mit Ani.js auch etwas anfangen. Ich würde auf jeden Fall gerne mal sehen, wo Ani.js bald eingesetzt wird, also hinterlass mir doch einen Kommentar!