Tutorials › Teil 3: Die Seite

Kapitel 15 von 16

Zeichnen und animieren mit Canvas

Ein <canvas> ist eine leere Fläche. Was darauf landet, entsteht ausschließlich durch Code.

<canvas id="bild" width="300" height="140" style="border:1px solid #ccc"></canvas>
<script>
  const stift = document.getElementById("bild").getContext("2d");

  stift.fillStyle = "#0e6e7e";
  stift.fillRect(20, 20, 100, 60);

  stift.strokeStyle = "#b0402c";
  stift.lineWidth = 3;
  stift.strokeRect(150, 20, 100, 60);

  stift.fillStyle = "#241f1b";
  stift.font = "16px system-ui";
  stift.fillText("gefüllt und umrandet", 20, 115);
</script>

getContext("2d") liefert das Werkzeug, mit dem gezeichnet wird. Alles läuft über dieses eine Objekt.

Die Größe steht im Attribut

<canvas id="bild" width="300" height="140"></canvas>

Diese Zahlen bestimmen, wie viele Bildpunkte die Zeichenfläche hat. Sie sind nicht dasselbe wie die Größe im Layout.

Setzt du die Größe nur per CSS, wird eine 300 mal 150 große Fläche gedehnt, und alles wird unscharf. Setz width und height als Attribute, dann stimmt es.

Auf Bildschirmen mit hoher Auflösung braucht es einen Schritt mehr:

<canvas id="c" style="width:280px;height:100px;border:1px solid #ccc"></canvas>
<script>
  const c = document.getElementById("c");
  const dichte = window.devicePixelRatio || 1;

  c.width = 280 * dichte;
  c.height = 100 * dichte;

  const stift = c.getContext("2d");
  stift.scale(dichte, dichte);

  stift.font = "600 20px system-ui";
  stift.fillStyle = "#241f1b";
  stift.fillText("scharf, auch auf Retina", 12, 55);

  zeige("Pixeldichte dieses Bildschirms:", dichte);
</script>

Das Koordinatensystem

Nullpunkt oben links. X wächst nach rechts, Y nach unten. Wer aus dem Mathematikunterricht kommt, muss sich an die umgekehrte Y-Achse gewöhnen.

fillRect(x, y, breite, hoehe) beginnt an der linken oberen Ecke.

Kreise und Pfade

Für alles außer Rechtecken beschreibt man erst einen Pfad und zeichnet ihn dann.

<canvas id="c" width="300" height="150" style="border:1px solid #ccc"></canvas>
<script>
  const s = document.getElementById("c").getContext("2d");

  s.beginPath();
  s.arc(70, 75, 45, 0, Math.PI * 2);
  s.fillStyle = "#8c5a12";
  s.fill();

  s.beginPath();
  s.arc(180, 75, 45, 0, Math.PI);
  s.strokeStyle = "#1d5d8c";
  s.lineWidth = 4;
  s.stroke();

  s.beginPath();
  s.moveTo(240, 30);
  s.lineTo(290, 75);
  s.lineTo(240, 120);
  s.closePath();
  s.fillStyle = "#2e6b4e";
  s.fill();
</script>

arc(x, y, radius, startwinkel, endwinkel) rechnet in Bogenmaß. Ein voller Kreis ist Math.PI * 2, ein halber ist Math.PI.

beginPath nicht vergessen. Ohne es hängt der neue Pfad am alten, und dann wird alles zusammen gefüllt.

Umrechnung von Grad in Bogenmaß, falls du sie brauchst: grad * Math.PI / 180.

Animation

Der Ablauf ist immer derselbe. Fläche löschen, neuen Zustand berechnen, neu zeichnen, nächsten Durchgang anfordern.

<canvas id="c" width="300" height="120" style="border:1px solid #ccc"></canvas>
<script>
  const c = document.getElementById("c");
  const s = c.getContext("2d");

  let x = 20;
  let richtung = 2;

  function zeichnen() {
    s.clearRect(0, 0, c.width, c.height);

    s.beginPath();
    s.arc(x, 60, 16, 0, Math.PI * 2);
    s.fillStyle = "#b0402c";
    s.fill();

    x += richtung;
    if (x > c.width - 16 || x < 16) richtung = -richtung;

    requestAnimationFrame(zeichnen);
  }

  zeichnen();
</script>

requestAnimationFrame bittet den Browser, die Funktion vor dem nächsten Bildaufbau aufzurufen. Meist sechzig Mal pro Sekunde, auf schnellen Bildschirmen öfter.

Warum nicht setInterval(zeichnen, 16)? Weil setInterval stur weiterläuft, auch wenn der Tab im Hintergrund liegt, und weil es nicht mit dem Bildaufbau des Browsers synchronisiert ist. Das kostet Akku und ruckelt.

Ohne clearRect bleibt jeder alte Zustand stehen. Manchmal ist genau das erwünscht, dann lässt man es weg und bekommt eine Spur.

Geschwindigkeit unabhängig vom Bildschirm

Das Beispiel oben bewegt sich pro Bild um zwei Punkte. Auf einem 144-Hertz-Bildschirm läuft es mehr als doppelt so schnell wie auf einem mit 60 Hertz.

Sauberer ist es, die vergangene Zeit einzurechnen:

<canvas id="c" width="300" height="90" style="border:1px solid #ccc"></canvas>
<script>
  const c = document.getElementById("c");
  const s = c.getContext("2d");

  let x = 20;
  let richtung = 1;
  let vorher = performance.now();
  const tempoProSekunde = 140;

  function bild(jetzt) {
    const delta = (jetzt - vorher) / 1000;
    vorher = jetzt;

    x += richtung * tempoProSekunde * delta;
    if (x > c.width - 14 || x < 14) richtung = -richtung;

    s.clearRect(0, 0, c.width, c.height);
    s.beginPath();
    s.arc(x, 45, 14, 0, Math.PI * 2);
    s.fillStyle = "#14707c";
    s.fill();

    requestAnimationFrame(bild);
  }

  requestAnimationFrame(bild);
</script>

requestAnimationFrame übergibt der Funktion einen Zeitstempel. Die Differenz zum letzten Bild sagt, wie weit sich etwas bewegt haben müsste. Damit läuft die Animation überall gleich schnell.

Steuerung dazu

Mit dem Wissen aus Kapitel 13 wird daraus ein Spiel.

<canvas id="c" width="320" height="180" tabindex="0"
        style="border:1px solid #ccc;background:#faf7f0"></canvas>
<p style="font:13px system-ui;color:#666">Erst ins Feld klicken, dann Pfeiltasten hoch und runter.</p>
<script>
  const c = document.getElementById("c");
  const s = c.getContext("2d");

  const schlaeger = { y: 70, hoehe: 50, tempo: 260 };
  const ball = { x: 160, y: 90, dx: 170, dy: 120, r: 8 };
  const tasten = {};
  let punkte = 0;
  let vorher = performance.now();

  c.addEventListener("keydown", (e) => {
    tasten[e.key] = true;
    if (e.key.startsWith("Arrow")) e.preventDefault();
  });
  c.addEventListener("keyup", (e) => { tasten[e.key] = false; });
  c.focus();

  function bild(jetzt) {
    const dt = Math.min((jetzt - vorher) / 1000, 0.05);
    vorher = jetzt;

    if (tasten.ArrowUp) schlaeger.y -= schlaeger.tempo * dt;
    if (tasten.ArrowDown) schlaeger.y += schlaeger.tempo * dt;
    schlaeger.y = Math.max(0, Math.min(c.height - schlaeger.hoehe, schlaeger.y));

    ball.x += ball.dx * dt;
    ball.y += ball.dy * dt;

    if (ball.y < ball.r || ball.y > c.height - ball.r) ball.dy = -ball.dy;
    if (ball.x > c.width - ball.r) ball.dx = -ball.dx;

    if (ball.x < 18 + ball.r && ball.dx < 0) {
      if (ball.y > schlaeger.y && ball.y < schlaeger.y + schlaeger.hoehe) {
        ball.dx = -ball.dx;
        punkte++;
      } else if (ball.x < -20) {
        ball.x = 160; ball.y = 90; punkte = 0;
      }
    }

    s.clearRect(0, 0, c.width, c.height);
    s.fillStyle = "#241f1b";
    s.fillRect(10, schlaeger.y, 8, schlaeger.hoehe);
    s.beginPath();
    s.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
    s.fillStyle = "#b0402c";
    s.fill();
    s.font = "600 14px ui-monospace, monospace";
    s.fillStyle = "#6b6156";
    s.fillText(String(punkte), c.width - 24, 22);

    requestAnimationFrame(bild);
  }

  requestAnimationFrame(bild);
</script>

Das Muster mit dem tasten-Objekt ist typisch für Spiele. Der Zuhörer merkt sich nur, ob eine Taste gedrückt ist, und die Bewegung passiert in der Zeichenschleife. Würde man direkt im keydown bewegen, hinge die Geschwindigkeit an der Tastenwiederholrate des Betriebssystems.

tabindex="0" macht das Canvas fokussierbar, sonst kommen bei ihm gar keine Tastaturereignisse an.

Ein Thema fehlt noch, und zwar das, was passiert, wenn ein Programm auf etwas warten muss.