30. September 2013
Drag & Drop mit SVG
Für das Patience-Projekt hatte ich mit einigen Performance-Problemen zu kämpfen. Eine Spielkarte als SVG-Grafik im Browser darzustellen ist ansich völlig harmlos. Aber wenn die Karte mit der Maus über andere hinweg gezogen werden soll, oder für einen Kartenstapel viele übereinander dargestellt werden, werden Verzögerungen offensichtlich, die den Spielablauf behindern.
🔗 Karten als individuelle SVG-Dateien
Das D3-Framework verwendet Funktionen von Datenobjekten, um DOM-Elemente zu erzeugen und zu
manipulieren. Hier ist nicht der Platz, das Verfahren im Detail zu beschreiben, eine kurze
Einführung steht hier . In meinem ersten Ansatz
habe ich <img>
-Elementen Objekte zugeordnet, die die src
-URL enthalten:
{
key: // eindeutiger Schlüssel für Stapel, Kartenindex und dargestellte Karte
ref: "cards/card_(...).svg" // URL der svg-Datei für die Karte
}
Für jeden Kartenstapel werden diese Objekte in einem Array card_names
zusammengefasst.
In einem Konstruktor für die Klasse, die das Spielfeld erzeugt, wird zunächst ein dynamisches Stylesheet angelegt, um die Größe der dargestellten Karten anpassen zu können:
function Area(...) {
...
var sheet = d3.select("head").append("style").property("sheet");
sheet.insertRule("img {}", 0); // erzeuge dynamisches Stylesheet für Karten
this.rule = sheet.cssRules[0];
...
}
Je nach dem im Browserfenster zur Verfügung stehenden Platz wird ein Skalierungsfaktor ermittelt:
Area.prototype.resize = function () {
...
this.scale = ... // berechne Skalierungsfaktor
this.card_w = Math.round(101 * this.scale); // aktuelle Kartengrößen
this.card_h = Math.round(156 * this.scale);
this.rule.style.width = this.card_w + "px"; // schreibe Größen in das Stylesheet
this.rule.style.height = this.card_h + "px";
...
};
Bei der Zuordnung von Karten zu Kartenstapeln werden die erforderlichen <img>
-Elemente
in den Stapel eingefügt und für neu hinzugefügte Karten der URL-Verweis aus dem Datenobjekt
gesetzt:
Area.prototype.alter_pile = function (pile) {
...
var stack = this.get_stack(pile); // DOM-Element des Stapels
var card_names = to_view(pile); // erzeuge d(ata)-Objekt für jede Karte
var gd = stack.inner.selectAll("img").data(card_names, function (d) {
return d.key; // identifiziert schon existente Karten nach ihrem Schlüssel
});
gd.enter().append("img");
gd.attr("src", function(d) {
return d.ref;
});
...
};
Das Ergebnis ist für alle getesteten Browser (aktuelle Versionen von Chrome, Firefox und Opera, IE 9 und 10) enttäuschend: Werden Karten mit der Maus gezogen, gibt es eine erkennbare Verzögerung, bis sie aus ihrer Ausgangsposition „herausspringen“. Bei Ablage auf einem anderen Stapel dauert es bis zu mehreren Zehntelsekunden, bis sie dort dargestellt werden und das Spiel wieder auf Benutzeraktionen reagiert.
🔗 Rückfallposition: Hochaufgelöste PNG-Dateien
Die veröffentlichte Variante ersetzt die SVG-Dateien durch relativ hoch aufgelöste PNGs (200 × 308 Pixel). Zwar ergibt das für den vollständigen Kartensatz stolze 1,3 MB, aber durch Nutzung des Applikationscaches können Ladezeiten weitgehend minimiert werden.
Dies ist die einzige Möglichkeit, konsistent auf allen Browsern ein flüssiges Spielen zu ermöglichen. Trotzdem, es ist und bleibt eine Behelfslösung.
Wie lässt sich also die Skalierbarkeit von SVGs auf den lokalen Rechner bringen, ohne durch ständiges Neuzeichnen von Objekten, die eigentlich statisch bleiben, unnötige Rechenzeiten zu vergeuden? Ich habe zwei weitere Varianten getestet.
🔗 Bildausschnitte in canvas-Elemente kopieren
Bei diesem Ansatz sind die Karten in einer SVG-Datei zusammengefasst, die bei Initialisierung
in einem HTMLImageElement außerhalb des DOM-Baumes gerendert wird. Zur Darstellung werden dann
Ausschnitte mit jeweils einer Karte in canvas
-Elemente kopiert.
Das Datenobjekt enthält dafür keine URL mehr, sondern Koordinaten, die beschreiben, wo in dem Kartenbild die individuelle Karte zu finden ist:
{
key: // eindeutiger Schlüssel für Stapel, Kartenindex und dargestellte Karte
ref_x: card.value - 1, // Index der Spalte für die Karte in Karten.svg
ref_y: base.suit_names.indexOf(card.suit), // Index der Zeile
}
Da die Größenangaben als direkte Attribute erforderlich sind, kann die Kartengröße nicht über ein dynamisches Stylesheet global gesetzt werden:
function Area(...) {
...
this.karten = new Image(); // Offscreen-Image
d3.select(this.karten).on("load", function() {
...
});
this.karten.src = "cards/Karten.svg"; // Datei enthält alle Kartenbilder in einem Raster
}
Area.prototype.resize = function () {
...
this.scale = ... // berechne Skalierungsfaktor
this.card_w = Math.round(101 * this.scale); // aktuelle Kartengrößen
this.card_h = Math.round(156 * this.scale);
... // zeichne alle bestehenden Karten neu
};
Area.prototype.alter_pile = function (pile) {
...
var stack = this.get_stack(pile); // DOM-Element des Stapels
var card_names = to_view(pile); // erzeuge d(ata)-Objekt für jede Karte
var gd = stack.inner.selectAll("canvas").data(card_names, function (d) {
return d.key; // identifiziert schon existente Karten nach ihrem Schlüssel
});
var self = this;
gd.enter().append("canvas")
.attr("width", this.card_w).attr("height", this.card_h) // aktuelle Kartengröße zuweisen
.each(function (d) {
var ctx = this.getContext('2d'); // this verweist auf das aktuelle canvas-Element
ctx.clearRect(0, 0, self.card_w, self.card_h); // leeren und neu zeichnen
ctx.drawImage(self.karten, d.ref_x * 101, d.ref_y * 156, 101, 156, 0, 0,
self.card_w, self.card_h);
});
...
};
Diese Lösung funktioniert mit Chrome (fast) einwandfrei, das Spielerlebnis ist flüssig und die
Kartendarstellung skaliert mit guten Rendering-Zeiten. Leider trübt ein Fehler das Bild:
<svg:pattern>
-Füllelemente verweigern sich der Skalierung und werden als verwaschene Pixel
dargestellt.
Der Internet Explorer stellt die Karten fehlerfrei dar, die Rendering-Zeiten sind suboptimal, aber noch einigermaßen erträglich.
Firefox beherrscht die Skalierung der erzeugten Bildausschnitte nicht und zeigt die Karten immer nur in der Basisauflösung 101 × 156 Pixel.
Opera scheitert kläglich. Weder kann er Bilder offscreen rendern, noch scheint es, dass
canvas.drawImage()
implementiert ist.
🔗 SVG als Hintergrundbild
Diese Lösung setzt auf CSS. Die Karten werden als
{
key: // eindeutiger Schlüssel für Stapel, Kartenindex und dargestellte Karte
ref: "url(cards/card_(...).svg)" // funcURL der svg-Datei für die Karte
}
Es kommt wieder ein dynamisches Stylesheet zum Einsatz, beim Aktualisieren der Stapel muss nur das Hintergrundbild ausgetauscht werden:
function Area(...) {
...
var sheet = d3.select("head").append("style").property("sheet");
sheet.insertRule("div.bgcard {}", 0); //erzeuge dynamisches Stylesheet für Karten
this.rule = sheet.cssRules[0];
...
}
Area.prototype.resize = function() {
...
this.scale = ... // berechne Skalierungsfaktor
this.card_w = Math.round(101 * this.scale); // aktuelle Kartengrößen
this.card_h = Math.round(156 * this.scale);
this.rule.style.width = this.card_w + "px"; // schreibe Größen in das Stylesheet
this.rule.style.height = this.card_h + "px";
this.rule.style.backgroundSize = this.card_w + "px";
...
};
Area.prototype.alter_pile = function(pile) {
...
var stack = this.get_stack(pile); // DOM-Element des Stapels
var card_names = to_view(pile); // erzeuge d(ata)-Objekt für jede Karte
var gd = stack.inner.selectAll("div").data(card_names, function (d) {
return d.key; // identifiziert schon existente Karten nach ihrem Schlüssel
});
var self = this;
gd.enter().append("div").classed("bgcard", true)
.style("background-image", function (d) {
return d.ref;
});
};
Wieder zeigt sich, dass Chrome am besten abschneidet und alles flüssig und korrekt darstellt.
Internet Explorer erfordert hier Version 10, in Version 9 fehlen noch einige CSS3-Funktionen.
Firefox bietet eine geradezu unterirdische Performance, beim Laden von Spielen wurden bei mir mehrfach die maximalen Scriptausführungszeiten überschritten.
Opera erzeugt mehrere Fehler: Die Bilder werden bei Skalierung nicht neu neu gerendert, und bei der Neuerzeugung von Kartenbildern kann es sein, dass sie auf dem Bildschirm gar nicht erscheinen, bis der Fensterpuffer insgesamt neu geschrieben wird.