Benutzer:Rolf b/animation

Aus SELFHTML-Wiki
Wechseln zu: Navigation, Suche

Animation mit JavaScript

Wenn man im Browser animierte Objekte darstellen möchte, kann man dafür in einfachen Fällen auf die CSS Eigenschaft animation bzw. auf das WebAnimation API zurückgreifen. Mit geeigneten Keyframes lässt sich für vordefinierte Animationen einiges erreichen, ohne programmieren zu müssen.

Anders ist es, wenn man Bewegungsabläufe programmieren möchte, die sich nicht als Abfolge von Keyframes definieren lassen, oder wenn Objekte auf Position und Verhalten anderer Objekte reagieren sollen. Dann muss die neue Position oder das neue Aussehen von Elementen Frame für Frame neu bestimmt werden.

Das Mittel der Wahl ist die requestAnimationFrame Methode des Window-Objekts. Sie erwartet einen Callback, der vom Browser einmal pro Displayzyklus des verwendeten Bildschirms aufgerufen wird, d.h. für übliche LCD Displays sechzig mal pro Sekunde. Grundsätzlich spricht nichts dagegen, für jedes zu animierende Objekt einen eigenen requestAnimationFrame Aufruf zu machen und einen eigenen Callback zu registrieren, man verliert dann allerdings die Kontrolle über die Reihenfolge, in der die Objekte bearbeitet werden. Zur Beibehaltung der Z-Ordnung - gerade wenn man Objekte auf ein canvas Element zeichnet, ist das aber notwendig.

Der folgende Artikel beschreibt, wie man mit JavaScript komplexe Animationen auf einer Webseite (z.B. für ein Spiel) koordinieren kann. Man kann die bewegten Objekte als Grafikelemente in einen <canvas> zeichnen, oder man verwendet normale HTML Elemente und setzt mittels CSS Eigenschaften ihre Position. Dieser Artikel verwendet zunächst CSS Positionierung und zeigt dann auch die Darstellung auf einem canvas Element.

Voraussetzungen

Sie sollten sich nicht mit diesem Tutorial beschäftigen, wenn Sie noch nicht viele Erfahrungen mit JavaScript gemacht haben. Die gezeigten Programme werden zwar Befehl für Befehl erklärt, aber die Kenntnis der grundlegenden Sprachelemente von JavaScript wird vorausgesetzt. Grundkenntnisse in HTML und CSS sind ebenfalls erforderlich. Viele spezifische Elemente, die verwendet werden, sind mit Links ins Wiki ausgestattet.

Das Modulsystem von ECMAScript 2015 wird verwendet und, wo nötig, auch kurz erklärt.

Die Kenntnis der ebenfalls von ECMAScript 2015 eingeführten class-Syntax wird als bekannt vorausgesetzt.

Werkzeuge

Darstellungsrahmen

Als Grundlage für die Beispiele wird ein einfacher HTML Rahmen verwendet, der lediglich einen Container anzeigt, in dem sich die animierten Objekte bewegen sollen. Darüber ist sind zwei Buttons zum Aktivieren der Animation und zum Abbruch der Animations-Engine. Für JavaScript werden ECMAScript 2015 Module verwendet, darum wird der Script-Teil mit type="module" eingebunden.

Beispiel
<!DOCTYPE html>
<html>
<head>
   <link rel="stylesheet" href="./anim_demo.css">
   <script type="module" src="./anim_demo.js"></script>
</head>
<body>
   <header>
      <h1>SelfAnimation Demo</h1>
      <button id="runAnim" type="button">Start</button>
      <button id="stopAnim" type="button">Stop</button>
   </header>
   <main>
      <div id="animContainer">
      </div>
   </main>
</body>
</html
Dazu gehört auch etwas CSS:
html, body { height: 100vh; margin:0; padding: 0; }

body { display: flex; flex-direction:column; }

header { flex: 0 0 auto; text-align:center; }

main { flex: 1 0 1em; padding: 1em 0; }

#animContainer {
	position:relative; 
	margin: auto; padding: 0; width: 90%; height: 100%; overflow:hidden;
	box-sizing: border-box; border: 5px ridge #88f; 
	background-color: #44f;
}

#animContainer * {
	position: absolute;
}

Mittels height:100vh und Flexbox wird dafür gesorgt, dass das main-Element das Browserfenster füllt. Das div mit id="animContainer" dient als Darstellungsfläche für die Animation. Die zu animierenden HTML Elemente werden absolut positioniert werden, damit sie frei beweglich sind, dafür dienen die position Angaben.

Solange nur wenige Objekte zu animieren sind, ist eine Steuerung nicht sonderlich komplex. Beispiele dafür findet man bei der Beschreibung von requestAnimationFrame. Spannender wird es, wenn viele Objekte ins Spiel kommen. Hier ist es hilfreich, einen zentralen Regisseur bereitzustellen, der die pro Animationsschritt aktiven Objekte verwaltet. Ein solcher Regisseur, oder Scheduler (engl. Planer, Steuerer), soll nun vorgestellt werden.

Um einen Regisseur nutzen zu können, braucht man noch etwas anderes: regierbare Objekte. Auf diese soll daher zuerst eingegangen werden.

Eine Superklasse für animierbare Objekte

Hinweis:
Die gezeigten JavaScript Sourcen werden die mit ECMAScript 2015 eingeführte Klassen- und Modulsyntax verwenden. Deshalb ist im <script> Element das Attribut type="module" erforderlich.

ECMAScript bietet noch keine brauchbare Unterstützung von privaten Eigenschaften und Methoden, die kommende # Syntax wird noch nicht breit von den Browsern unterstützt und auf Transpiler möchte ich für diesen Artikel nicht zurückgreifen. Private Eigenschaften und Methoden werden darum einfach durch einen Unterstrich als Namenspräfix markiert.

Bitte nehmen Sie an, dass im folgenden gezeigten Sourcecodes jeweils in einer eigenen JavaScript Datei stehen und mittels des import-Statements geladen werden. Eine Diskussion des gezeigten Sourcecode folgt im Anschluss.
Animatable Objekt
// animatable.js

// Abstrakte Superklasse für animierbare Objekte.
export class Animatable {
	constructor(scheduler, startTime) {
		this._state = 0;
		this._startTime = startTime || 0;
		// Scheduler-Property readonly anlegen
		Object.defineProperty(this, "scheduler", { value: scheduler, writable:false });
	}

	// Diese Methode nicht überschreiben!
	step(time) {
		switch (this._state) {
			case 9:
				return false;
			case 1:
				this.update(time);
				break;
			case 0:
				if (time > this._startTime) {
					this._state = 1;
					this._startTime = time;
					this.start();
				}
				break;
		}
		return true;
	}
	
	get isPainting()  { return this._state === 1 || this._state === 2; }
	get isFinished()  { return this._state === 9; }
	get startTime()   { return this._startTime; }

	suspend() { this._state = 2; }
	resume() { this._state = 1; }
	
	// Überschreibe NUR diese Methoden für konkrete Implementierungen
	// Wird stop überschrieben, immer super.stop() aufrufen.
	start() { }
	stop() { this._state = 9; }          

	update(time) { }
	paint(context) { }
}

Objekte der Animatable-Klasse sind für sich noch nicht vollständig. Es ist eine abstrakte Superklasse, von der die eigentlichen animierbaren Objekte abzuleiten sind. Sie enthält Eigenschaften und Methoden, die der Animation Scheduler von seinen animierbaren Objekten erwartet, und noch ein paar Extra-Features.

  • einen Status (0 = uninitialisiert, 1 = in Animation, 2 = Animation pausiert, 9 = Animation beendet)
  • Eigenschaften, die diesen Status bereitstellen (isPainting, isFinished)
  • Methoden zum Initialisieren der Animation und zum Durchführen eines Animationsschritts

Die Idee ist, dass ein animierbares Objekt im Scheduler eingetragen wird und unter dessen Kontrolle an der Animation teilnimmt. Dafür muss das Objekt zwei Dinge tun können: sich in seinem aktuellen Zustand darstellen, und den Folgezustand berechnen. Für eine konsistente Darstellung ist es sinnvoll, die Darstellung von der Neuberechnung zu trennen. Aus diesem Grund bietet das Animatable-Objekt zwei Methoden an: paint und step. Die paint-Methode ist in der Superklasse nur ein Platzhalter und muss vom konkreten Objekt implementiert werden. Die step-Methode wird dagegen nur von Animatable implementiert. Sie reagiert auf den aktuellen Zustand des animierten Objekts und ruft start oder update auf.

Hinweis:
JavaScript kennt im Gegensatz zu Java keine final-Methoden und kann darum nicht verhindern, dass jemand step überschreibt.

Im Status 0 wartet das Objekt darauf, dass sein Startzeitpunkt erreicht ist, der eventuell dem Konstruktor übergeben wurde. Sobald das der Fall ist, wird der Status auf 1 gesetzt, die echte Startzeit gespeichert und start aufgerufen. Das animierbare Objekt berechnet darin seine Startpostition und führt eventuelle weitere Initialisierungen durch (sollte sich dabei aber kurz fassen um die Animation nicht zu behindern).

Im Status 1 findet reguläre Animation statt. Der Aufruf der update-Methode gibt dem animierten Objekt die Gelegenheit, seine Position neu zu berechnen und eventuelle weitere Optionen für die Darstellung zu setzen. Wichtig für eine canvas-Animation: der Aufruf der paint()-Methode ist bereits erfolgt. Hier ist also die Position für den nächsten Frame zu berechnen.

Status 2 dient für Objekte, deren Animation unterbrochen ist. Dieser Zustand wird über die Methode suspend herbeigeführt. Zweck ist, dass ein Objekt zwar noch angezeigt wird, aber keine Updates mehr benötigt. Er kann durch die Methode resume wieder beendet werden, aber nicht vom Objekt selbst (weil es keine update-Aufrufe mehr bekommt), sondern nur über ein anderes Steuer-Objekt.

Im Status 9 schließlich findet keine Animation und keine Darstellung mehr statt. Dem Scheduler wird das durch Rückgabe von false mitgeteilt, um ihm die Gelegenheit zum Aufräumen seiner Objektliste zu geben.

Der Animation-Scheduler

Basierend auf solchen Animatable Objekten kann man nun einen Scheduler konstruieren:

AnimationScheduler
// animScheduler.js

export class  AnimationScheduler {
	constructor() {
		this._animObjects = [];	// Array der vom Scheduler verwalteten Animatables
		this._running = false;	// Ein-/Ausschalter für die Animation
	}
	// Einschalten der Animation
	run() {
		this._running = true;
		this._requestFrame();
	}
	// Beenden/Unterbrechen der Animation.
	end() {
		this._running = false;
	}
	// Übergeben eines neuen Animatable zur Teilnahme an der Animation
	add(newItem) {
		this._animObjects.push(newItem);
	}
	// Wieviele Animatables gibt es (ggf. sind nicht alle aktiv)
	get count() { return this._animObjects.length; }

	// Animationsschleife, gebildet durch Aufruf von _requestStep 
	// am Ende des Animation-Callback
	_requestFrame() {
		window.requestAnimationFrame(time => {
			let numObjects = this._animObjects.length;
			// Schritt 1: Alle aktiven Objekte zeichnen
			for (let i=0; i<numObjects; i++) {
				let a = this._animObjects[i];
				if (a && a.isPainting)
					a.paint();
			}
			// Schritt 2: Alle Objekte updaten. Aber nicht im AnimationFrame-Callback!
			Promise.resolve().then(() => {
				let garbage = 0;
				for (let i=0; i < numObjects; i++) {
					if (!this._animObjects[i].step(time))
						garbage++;
				}
				if (garbage > 10)
					this._animObjects = this._animObjects.filter(a => !a.isFinished);
				if (this._running)
					this._requestFrame();
			});
		});
	}
}

Der AnimationScheduler verwaltet die Animatables als einfaches Array. Neu hinzugefügte Animatables werden dem Array am Ende hinzugefügt. Für eine CSS Animation ist das ausreichend; eine Steuerung der Z-Order kann mittels CSS erfolgen. Für eine Canvas-Animation sind hier weitere Überlegungen erforderlich, weil man zur Darstellung einer korrekten Z-Order von hinten nach vorn zeichnen muss. Dazu später mehr.

Die eigentliche Animationsschleife wird mittels der requestAnimationFrame-Methode des window-Objekts gebildet. Diese Methode bekommt eine Callback-Funktion, die beim nächsten Bildwechsel des Computer-Monitors aufgerufen wird. Für heutige LCD-Monitore bedeutet dies eine Frame-Rate von 60 Bildern pro Sekunde. Um möglichst schnell die eigentliche Darstellung der Objekte zu erreichen, wird zunächst für alle aktiven Objekte die paint-Methode aufgerufen. Danach erfolgt eine Entkopplung vom eigentlichen AnimationFrame. Promise.resolve() liefert ein Promise im resolved-Status, d.h. die an then() übergebene Callback-Funktion wird unverzüglich ausgeführt. Aber eben nicht synchron und sofort, sondern asynchron über die Mikrotask-Queue des Browsers. Ein synchron ausgeführter Update würde andere AnimationsFrame-Callbacks eventuell an ihrer rechtzeitigen Ausführung hindern.

Die Animationsobjekte, deren step-Methode false zurückliefert, werden gezählt. Sind es mehr als 10 (eine willkürliche Zahl), wird das Array der Animationsobjekte aufgeräumt. Sinn ist hier, den dafür erforderlichen Kopiervorgang nicht zu häufig ausführen zu müssen.

Hinweis:
Die for-Schleife für die step-Aufrufe läuft nicht blindlings bis this._animObjects.length. Es ist möglich und zulässig, dass ein animiertes Objekt andere animierte Objekte erzeugt - dazu wird ein Beispiel folgen. Diese Objekte sollten aber erst im nächsten Frame an der Animation teilnehmen, um Unordnung zu vermeiden.

Das Steuerprogramm

Bevor wir uns einem ersten animierten Objekt zuwenden, brauchen wir noch das Steuerprogramm, das vom HTML-Rahmen als Script angegeben wurde: anim_demo.js.

Animationsdemo - Steuerprogramm
// anim_demo.js

import { AnimationScheduler } from "./animScheduler.js";
import { TimeTracer } from "./timetracer.js";

// Initialisierung erst bei DOMContentLoaded ausführen, sofern das Dokument noch lädt.
if (document.readyState == "loading") {
	document.addEventListener("DOMContentLoaded", initDemo);
} else {
	initDemo();
}

function initDemo() {
	let sched = createScheduler();

	registerClick("runAnim", function() { sched.run(window); });
	registerClick("stopAnim", function() { sched.end(); });
}

// Hilfsfunktion: Erzeuge einen neuen AnimationScheduler
function createScheduler() {
	let sched = new AnimationScheduler();
	sched.add(new TimeTracer(sched, document.getElementById("animTime")));
	return sched;
}

// Hilfsfunktion: Registriere eine Funktion auf das click-Event eines Elements
function registerClick(id, func) {
	let e = document.getElementById(id);
	if (e) e.addEventListener("click", func);
}

Sie sehen hier Elemente der Modulsyntax von ECMAScript. Die import Anweisungen in einem Modul führen zum Nachladen der angegebenen .js Dateien. In diesen Dateien werden Dinge exportiert, in animScheduler.js beispielsweise die Klasse AnimationScheduler. Das oben gezeigte import-Statement macht diese Klasse im Hauptprogramm verfügbar.

Die Funktion initDemo des Hauptprogramms erzeugt ein AnimationScheduler-Objekt und registriert inline-Funktionen als click-Handler für die Start- und Stop-Buttons. Die beiden click-Handler enthalten eine Referenz auf die sched-Variable, was dafür sorgt, dass auch nach Ende von initDemo Referenzen auf den erzeugten AnimationScheduler existieren und das Objekt nicht vom Garbage Collector entfernt wird. Eine globale Variable zum Speichern des Schedulers ist nicht nötig.

Der Aufruf von initDemo ist so gestaltet, dass er unabhängig vom Ausführungszeitpunkt des Moduls funktioniert. Falls das HTML Dokument noch lädt, wird der Aufruf in einem DOMContentLoaded Handler gekapselt. Andernfalls sind alle benötigten Elemente im DOM bereits vorhanden und die Initialisierung kann sofort starten.

Was hat es nun mit diesem TimeTracer auf sich, der da auf einmal aufgetaucht ist? Er soll unser erstes „animiertes“ Objekt sein.

Anwendungsbeispiele

TimeTracer

Wenn man mit Animationen experimentiert, ist es praktisch, wenn man messen kann was geschieht. Interessant ist vor allem die Framerate, d.h. wieviele Animationsschritte pro Sekunde kann man ausführen.

Einen solchen Tracer wollen wir als erstes bauen. Er macht zwar noch keine Bewegung auf den Bildschirm, aber an Hand seines Aufbaues sollen einige Aspekt bei der Verwendung eines Animatable-Objekts erklärt werden.

Zunächst ergänzen wir den <header> unserer Demo-Seite durch ein Ausgabefeld, einfach hinter den Buttons hinzufügen:

Beispiel
    <div id="animTime">0</div>
Zum Platzieren dieses Feldes gehört noch etwas CSS:
header { position: relative; }

header #animTime { position: absolute; top: 1em; right: 2em; 	
                   text-align: right; width: 25em; border: thin solid black; }

Wie oben zu sehen war, bekommt der TimeTracer eine Referenz auf den Scheduler übergeben, sowie das DOM Element, in dem er seine Ausgaben machen soll. Der JavaScript-Code sieht so aus:

Beispiel
import { Animatable } from "./animatable.js";

// TimeTracer ist ein Animatable, das Statistiken zur Animation darstellt
export class TimeTracer extends Animatable {

	constructor(sched, elem) {
		super(sched);
		this._frameStat = [];
		this.element = elem;
		this._statValue = "";
	}
	
	start(time) {
		this._prevTime = time;
	}
	
	update(time) {
		let frameTime = Math.floor((time - this._prevTime)*.06+.03);
		if (frameTime > 5) frameTime = 5;
		this._frameStat[frameTime] = (this._frameStat[frameTime] || 0) + 1;
		
		let frames = this._frameStat.map((x,i) => x ? (i+(i==5?"+:":":")+x) : "").filter(x => !!x).join(", ")
	
		this._statValue = (time-this.startTime).toFixed(0) + " / " + this.scheduler.count + " anims" + frames;
		this._prevTime = time;
	}
	
	paint() {
		this.element.textContent = this._statValue;
	}
}

Im Konstruktor des TimeTracers wird zunächst der Konstruktor der Superklasse aufgerufen. Werfen Sie nochmal einen Blick auf animatable.js - dessen Konstruktor kann den Scheduler als Parameter empfangen. Die Superklasse Animatable fängt selbst damit nichts an, stellt aber für die abgeleiteten Klassen ein Property scheduler zur Verfügung, worüber jederzeit auf den Scheduler zugegriffen werden kann. Den zweiten Parameter (startTime) des Animatable-Konstruktors lassen wir weg, wir wollen keine Startverzögerung.

Darüber hinaus legt der Konstruktor ein privat markiertes Array an, in dem wir zählen werden, wie oft welcher zeitliche Abstand zwischen zwei Animationen vorkam. Und er bereitet ein privat markierts Property vor, das den Ausgabewert enthält. Wie oben beschrieben, soll das Ermitteln des nächsten Zustands und die Darstellung getrennt erfolgen, deshalb wird dieser Zwischenspeicher benötigt.

Die Methode paint tut nichts weiter, als diesen Wert als Text in das dafür vorgesehene Element zu schreiben.

Die update Methode führt die Berechnungen durch. Sie geht davon aus, dass Ihr Monitor 60 Bilder pro Sekunde anzeigt. Haben Sie eine andere Bildwiederholfrequenz, müssen Sie das ändern. Haben Sie einen Monitor mit variabler Bildfrequenz, funktioniert dieser TimeTracer leider nicht.

Im ersten Schritt wird die Zeit ermittelt, die seit dem letzten update Aufruf vergangen ist. Der Parameter time wird von requestAnimationFrame an den AnimationScheduler übergeben und von diesem weitergereicht. Beim ersten Aufruf des requestAnimationFrame-Callbacks auf einer Seite wird time=0 gesetzt, alle folgenden Aufrufe die seitdem vergangene Zeit in Millisekunden. Wichtig zu wissen ist: alle update-Aufrufe, die zu einem Animation-Frame des Browsers gehören, bekommen den gleichen time-Wert geliefert, selbst dann, wenn mit requestAnimationFrame mehrere Callbacks angefordert wurden. Der Wert von time wird, um die Anzahl abgelaufener Animation-Frames zu zählen, auf ein Vielfaches von 60ms gerundet, und auf 5 begrenzt um die Anzeige nicht zu breit werden zu lassen. Eine Verzögerung von 5 Animations-Frames bedeutet bereits, dass Ihre Berechnungen 300ms gedauert haben und Ihre Animation schrecklich ruckelt.

Das Zähl-Array wird nun als String aufbereitet. Dazu werden die Array-Methoden map, filter und join verwendet. Mit map wird der i-te Eintrag mit Wert x zu "i: x" aufbereitet, wenn also zum Beispiel _frameStat[3] den Wert 100 enthält, entsteht "3: 100". Eine Besonderheit ist für Index 5 vorgesehen, da wird noch ein + vor den Doppelpunkt gesetzt, um anzudeuten, dass es 5 oder mehr Einträge sein können. Ist x == 0, wird nur ein Leerstring erzeugt. Diese Leerstrings werden dann mit filter entfernt und die verbleibenden Elemente mittels join zu einem kommaseparierten String verbunden.