Egal welche Game Engine du verwendest, wenn du ein Spiel programmierst, wirst du in fast jedem Fall Animationen für deine Grafiken benötigen, denn ein Spiel ohne animierte Objekte ist doch ziemlich langweilig. Aber wie geht man so eine Aufgabe an?

Du weißt bereits, wie du ein Standbild in Photoshop oder Aseprite in Bewegung bringen kannst. Notfalls bekommst du sogar ein GIF exportiert. Aber wie spielst du das in deiner Game Engine ab? - Die einfachste und verbreitetste Lösung heißt Spritesheets.

Nachfolgend werde ich dir zwei Techniken aufzeigen, wie du ein einfaches Animations-System mittels Code in deinem Spiel umsetzen kannst.

Der Grundgedanke ist folgender: Du zeichnest dein Sprite. Dann animierst du es. Anschließend erstellst du eine große Arbeitsfläche auf der alle einzelnen Frames deiner Animation gelistet werden - etwa so:


Wichtig ist zunächst, dass jedes Frame die selbe Größe hat und alle Frames nahtlos an einander gereiht werden.


Das ist dein Spritesheet! Dieses findet letzten Endes Verwendung in der Engine, mehr brauchst du nicht. - Viele Programme (wie Aseprite) haben eine entsprechende Export-Option für Spritesheets, sodass man diesen Schritt (das Packing) nicht manuell machen muss. - Nun hast du also aus deinem animierten Sprite ein Spritesheet erstellt und musst es nur noch irgendwie in dein Spiel implementieren und dort mittels Code wieder animieren.

Zuerst reden wir über die einfache Illusion eines Fensters...

Ich versuche den Sachverhalt so einfach wie möglich zu erklären.

In deinem Spiel musst du dir jetzt eine Maske erstellen, also ein kleines Fenster, mit der Größe eines einzigen Frames aus deiner Animation. Durch diese Maske schaust du ab jetzt auf dein Spritesheet.


Zum Animieren wird das Spritesheet einfach hinter dem Fenster hin und her geschoben. Damit verändert sich der sichtbare Bereich, den man durch die Maske auf das Spritesheet hat und es entsteht ein Gefühl von Bewegung.


Nun zeige ich dir die Implementierung in Codea, weil es meine Lieblings-Engine ist. Aber die Logik dahinter lässt sich auf jede beliebige Engine übertragen. In LÖVE2D kann man den Code sogar fast 1:1 wiederverwenden.

Zuerst musst du dein Spritesheet laden. Danach brauchen wir einige Informationen:

  • Wie groß ist ein Frame? (Breite x Höhe)
  • An welcher Position soll das animierte Sprite (bzw. die Maske) auf dem Bildschirm gezeichnet werden? (X und Y Position)
spr = readImage("Dropbox:spritesheet")
width = 16
height = 32
x = 200
y = 300

Mit den vorliegenden Informationen kannst du ein Ausschnitt (ein Frame) aus dem gesamten Spritesheet zeichnen. clip() ist hierbei der die wichtigste Funktionsaufruf. Diese Funktion ist das "Fenster" oder die "Maske" von der ich weiter oben gesprochen habe.

clip(x, y, width, height)
spriteMode(CORNER)
sprite(spr, x, y)

Nun haben wir ein Frame gezeichnet. Um das nächste Frame abzuspielen, müssen wir das gesamte Spritesheet nur ein Stück nach links schieben (und zwar um die breite der derzeitigen Maske). Das wiederholen wir dann für jedes weitere Frame.

Kommt man an das letzte Frame am rechten Rand des Spritesheets, dann schiebt man das Spritesheet wieder komplett nach rechts und ein Stück hoch.

Zum animieren benötigen wir also mehr Informationen über den Versatz des Spritesheets:

  • Wie schnell soll die Animation abgespielt werden? (Frames pro Sekunde)
  • An welcher Position soll das Spritesheet zu jedem Frame gezeichnet werden? (X und Y Versatz)
fps = 6  -- Geschwindigkeit
anim = { -- Versatz (Breite * n und Höhe * m)
    vec2(-1, 0), -- Position Frame 1
    vec2(-2, 0), -- Position Frame 2
    vec2(-3, 0)  -- Position Frame 3
}

Damit wir uns nicht mit der tatsächlichen Anzahl an Pixel für den Versatz herumschlagen müssen, geben wir hier nur den Multiplikator ein. vec2(-1, 0) bedeutet, dass wir das gesamte Spritesheet um width * (-1) und 0, also um 16px nach links und um 0px nach oben verschieben müssen, damit wir das erste Frame zu sehen bekommen.

Da wir unsere Animation mit 6 Frames pro Sekunde abspielen wollen, verschieben wir das Spritesheet natürlich nicht immer sondern, erst nach Ablauf von 0,167 Sekunden (errechnet aus 1 Sekunde geteilt durch 6 Frames pro Sekunde). Dazu starten wir einen Timer und zählen die Zeit. Wenn der Augenblick für einen Frame-Wechsel gekommen ist, erhöhen wir die Variable frame und setzen einen neuen Timer fest.

if not timer or timer <= ElapsedTime then
     timer = ElapsedTime + 1 / fps
     frame = (frame or 1) + 1
     if frame > #anim then frame = 1 end
end

sprite(spr, x + width * anim[frame].x, y + height * anim[frame].y)

Damit sind wir auch schon fertig. Nun weißt du, wie du eine Animation mit Hilfe einer Maske abspielen kannst. Hier ist der vollständige Code, den du per Copy&Paste in Codea einfügen und verwenden kannst:

function setup()
    spr = readImage("Dropbox:spritesheet")
    x = 200
    y = 300
    width = 16
    height = 32
    fps = 6
    anim = { -- offsets
        vec2(-1, 0),
        vec2(-2, 0),
        vec2(-3, 0)
    }
end

function draw()
    if not timer or timer <= ElapsedTime then
        timer = ElapsedTime + 1 / fps
        frame = (frame or 1) + 1
        if frame > #anim then frame = 1 end
    end

    clip(x, y, width, height)
    spriteMode(CORNER)
    sprite(spr, x + width * anim[frame].x, y + height * anim[frame].y)
end



Super, das ist ja alles schön leicht!

Nun möchte ich dir eine andere Technik vorstellen. Es handelt sich dabei um sogenannte Meshes. Dieser Begriff kommt aus dem 3D Bereich und beschreibt ein dreidimensionales Objekt im dreidimensionalen Raum, wie zum Beispiel einen Würfel. Welchen Bezug hat das zu unserem animierten Sprite, fragst du dich?


Ein Beispiel: Man erstellt vier Punkte und verbindet diese miteinander. Dadurch erhält man eine rechteckige Fläche (auch Face genannt). Das ist nur ein Teil des Würfels, aber auch das ist schon ein Mesh! Diese Fläche kann man nun texturieren (ihr also ein Bild zuweisen). Beim texturieren wird es aber noch viel interessanter, denn man kann jeden der vier Punkte des Rechtecks auf das Textur-Bild einzeln plottern! Man nennt diesen Prozess auch UV-Mapping.

Das heißt wir können aus unseren Spritesheet ein Ausschnitt auf die Fläche des Rechtecks projizieren!

Das wäre dann auch schon wieder unsere Maske, von der wir vorhin gesprochen haben. Nun wäre es uns ein Leichtes diese Textur-Plott-Koordinaten zu ändern und dadurch eine Animation abzuspielen.

Praktischerweise sind wir nicht nur an eine Rechtecksform gebunden, sondern können die Maske in jeder denkbaren Form anlegen (Dreieck, Kreis, Würfel, Menschenkörper, etc). Sicherlich wirst du verstehen, welche Möglichkeiten sich dir dadurch ergeben!

Wir wagen uns langsam heran. Zuerst benötigen wir ein Mesh und dazu gibt es in Codea natürlich eine Funktion. Sobald ein leeres Mesh erstellt ist, können wir eine rechteckige Fläche (ein Face) hinzufügen. Diesem Face werden wir anschließend eine Textur zuweisen - unser Spritesheet.

Das Spritesheet wird einer Variable texture innerhalb des Mesh zugewiesen, damit weiß das Mesh was es auf die Fläche projizieren soll, während addRect() und setRectTex() Funktionsaufrufe sind um ein rechteckiges Face zu erstellen und die Textur-Koordinaten auf das Face zu pinnen. Man kann alle Vertices (Punkte des Mesh) sowie Textur-Koordinaten (UV's) auch manuell plottern, aber die beiden Funktionsaufrufe erleichtern diese Aufgabe und erledigen alles ohne unser zutun. Für primitive Formen, wie Rechtecke, ist das auch gut so.

function setup()
    msh = mesh()
    msh.texture = readImage("Dropbox:spritesheet")
    msh:addRect(0, 0, msh.texture.width, msh.texture.height)
    msh:setRectTex(1, 0, 0, 1, 1)
end

function draw()
    msh:draw()
end

Wie du siehst, haben wir erfolgreich ein Mesh (in der Größe unseres Spritesheet) mit einem rechteckigen Face erstellt und haben das Spritesheet als komplette Textur daran gepinnt. Nun wollen wir versuchen ein einziges Frame anzuzeigen. Dazu müssen wir nur unsere UV-Koordinaten (also die Koordinaten der vier Eckpunkte unseres Mesh-Face) anpassen.


UV's werden immer von 0,0 (linke obere Ecke) bis 1,1 (rechte untere Ecke) angegeben. Das hatten wir aber schon und haben festgestellt, dass wir damit die gesamte Textur pinnen, weshalb wir zu der Erkenntnis kommen, dass wir Kommazahl-Koordinaten benötigen um über 0 aber unter 1 zu bleiben, denn dort, mitten in der Textur, befinden sich die einzelnen Frames unserer Animation.

Wenn du die Größe deines Spritesheet kennst und weißt wie groß ein einzelnes Frame ist, lassen sich daraus die Spalten (cols) und Zeilen (rows) auf dem Spritesheet ermitteln. Daraus kann man wiederum die UV-Koordinaten errechnen. Damit erfahren wir die Größe und Position jedes UV-Rechtecks das wir auf das Face pinnen wollen. Hier ist ein Rechenbeispiel:


function setup()
     frame_width = 16
     frame_height = 32

     msh = mesh()
     msh.texture = readImage("Dropbox:spritesheet")
     msh:addRect(0, 0, frame_width, frame_height)

     col = msh.texture.width / frame_width
     row = msh.texture.height / frame_height
     w = 1 / cols
     h = 1 / rows
     u = w * col
     v = h * row

     msh:setRectTex(1, u, v, w, h)
end

function draw()
     msh:draw()
end

Hinweis: Codea's Koordinaten-System ist genau anders herum als das der meisten anderen Game Engines. Die y-Achse ist gespiegelt (Negativ geht nach unten und Positiv geht nach oben). Mit der o.g. Methode bekommst du unter Umständen also das erste Frame in der linken unteren Ecke vom Spritesheet. Um in solch' einem Fall aber das oberste linke Frame zu bekommen, musst du v von 1 abziehen: v = 1 - h * row


Sehr schön! Du hast ein Frame. Stellt sich nur noch die Frage, wie du die UV's animieren kannst? Ganz einfach: wie in der vorher beschriebenen Technik auch. Du brauchst ein Table mit Positionen, durch die du dann springst und damit die Animation abspielst. Alternativ kannst du diese Positionen auch berechnen lassen, sobald ein Frame-Wechsel ansteht.

Doch wenn du beide Techniken ausgiebig betrachtet hast, wirst du sicherlich bereits festgestellt haben, dass du immer nur eine Animation abspielen kannst. Ein anderes Problem ist, dass wir nur einen Timer haben. Um mehrere animierte Sprites anzeigen zu können, brauchen wir also für jedes Sprite ein separates Table mit eigenem Timer und eigenen Positionsangaben.

Um das zu bewerkstelligen bedient man sich am besten einer Funktion. Dort definiert man ein separates Table für ein animiertes Sprite. Jedesmal wenn man diese Funktion dann aufruft, wird ein leeres Table erstellt und mit Daten eines zu animierenden Sprite gefüllt und zurückgegeben. Damit sind alle Animationen von einander unabhängig. Übrigens produziert der Funktionsaufruf mesh() auch eine Klasse, also auch eine Art Table.

Soweit die Theorie.


Ich denke du beginnst zu verstehen, daher werde ich dich nicht weiter mit hypothetischen Beispielen langweilen. Stattdessen werde ich nun etwas aus dem Hut zaubern und dir an die Hand geben. Es ist sind zwei Funktionen, die dir das Leben ungemein erleichtern werden! Importiere die beiden in dein Spiel und benutze so oft wie du magst.

Die erste Funktion uvTexture bezieht sich auf das UV-Mapping von dem wir eben gesprochen haben. Mit ihr kannst du allerhand sinnvoller Informationen über eine Textur (Spritesheet) einholen.

Du könntest zum Beispiel Informationen über alle verfügbaren Frames aus dem Spritesheet einholen: Sagen wir du hättest ein Spritesheet mit 42 animierten Frames - jedes 16x32px groß. Der Aufruf uvTexture(readImage("Dropbox:spritesheet"), 16, 32, 1, 42) würde dir ein Table zurück liefern. In diesem Table fändest du jedes einzelne Frame inklusive seiner UV-Koordinaten, Breite, Höhe und X/Y-Position auf dem Spritesheet vor. Außerdem weiß jedes Frame in welcher Spalte und Zeile es sich auf dem Spritesheet befindet.

Du könntest auch Informationen über das jeweils nächste Animations-Frame berechnen: Wenn du derzeit von Frame 1 auf Frame 2 springen musst, kannst du einfach uvTexture(readImage("Dropbox:spritesheet"), 16, 32, 2) aufrufen.

Man kann auch alle Tiles aus einem rechtwinkligen Auswahlbereich abfragen, egal wo dieser Bereich sich auf der Textur befindet. Die Funktion ist äußerst flexibel, was ihren Einsatz angeht!

-- Gather uv information about any rectangular region (set of tiles) on a texture
-- Get a sequence of all region-rects from i to j where each sub-region is a tile of width x height
-- The 'explicit'-flag returns only tiles enclosed by the overall region from i to j (skipping the appendices and in-betweens)
-- Regions are described by their index position on texture - reading from top left corner on texture, indices are: 1,2,3...n
-- i and j indices might also be passed as vec2(col, row) which is convenient when spritesheet dimensions grow over time and sprite indices might shift

function uvTexture(texture, region_width, region_height, i, j, explicit)
   local cols = texture.width / region_width
   local rows = texture.height / region_height

   -- Get sprite index from col and row
   local function get_id(cell)
       return (cell.y - 1) * cols + cell.x
   end

   -- Get col and row from sprite index
   local function get_cell(id)
       local rem = id % cols
       local col = (rem ~= 0 and rem or cols) - 1
       local row = rows - math.ceil(id / cols)
       return col, row
   end

   i = i and (type(i) == "number" and i or get_id(i)) or 1 -- be sure to deal always with number indices
   j = j and (type(j) == "number" and j or get_id(j)) or i

   local minCol, minRow = get_cell(i)
   local maxCol, maxRow = get_cell(j)
   local tiles = {}
   local region = {}

   -- Collect all tiles enclosed by i and j
   for k = i, j do
       local col, row = get_cell(k)
       local w = 1 / cols
       local h = 1 / rows
       local u = w * col
       local v = h * row

       if not explicit
       or (col >= minCol and col <= maxCol)
       then
           table.insert(tiles, {
               id = k, -- region rect index on spritesheet
               col = col + 1, -- example: tile at {col = 1, row = 1}
               row = row + 1, -- would be at the lower left corner, because of OpenGL and Codea convention!
               x = col * region_width, -- {x, y} is the lower left corner position of the tile at {col, row}
               y = row * region_height,
               width = region_width,
               height = region_height,
               uv = {
                   x1 = u,
                   y1 = v,
                   x2 = u + w,
                   y2 = v + h,
                   w = w,
                   h = h
               }
           })
       end
   end

   -- Sort tiles by column and row in ascending order
   table.sort(tiles, function(curr, list)
       return curr.row == list.row and curr.col < list.col or curr.row < list.row
   end)

   -- Describe the overall region-rect
   local region = {
       x = tiles[1].x,
       y = tiles[1].y,
       width = tiles[#tiles].x + tiles[#tiles].width - tiles[1].x,
       height = tiles[#tiles].y + tiles[#tiles].height - tiles[1].y,
       uv = {
           x1 = tiles[1].uv.x1,
           y1 = tiles[1].uv.y1,
           x2 = tiles[#tiles].uv.x2,
           y2 = tiles[#tiles].uv.y2,
           w = tiles[#tiles].uv.x2 - tiles[1].uv.x1,
           h = tiles[#tiles].uv.y2 - tiles[1].uv.y1
       }
   }

   return region, tiles
end



Bei der zweiten Funktion ssprite handelt es sich um das Gegenstück zu uvTexture, das die errechneten Informationen verwenden und visuell darstellen kann. Hiermit kannst du animierte Sprites erstellen und zeichnen - und das mit minimalem Aufwand. Es wir nur ein Argument übergeben, ein Table mit bis zu zehn Konfigurationsmöglichkeiten.

-- Create textured and animated mesh quad
-- Note: available animations are listed as pairs of `name = {list of frames as vec2}`
--
-- @params {}:
-- texture: image
-- tilesize: vec2
-- spritesize: vec2
-- position: vec2
-- pivot: vec2 [0-1]
-- animations: {}
-- current_animation: "string"
-- fps: number
-- loop: boolean
-- tintcolor: color()

function ssprite(params)
   local quad = mesh()
   local _draw = quad.draw

   for name, prop in pairs(params) do
       quad[name] = prop -- copy all params
   end

   quad.tintcolor = quad.tintcolor or color(255)
   quad.spritesize = quad.spritesize or quad.tilesize
   quad.position = quad.position or vec2()
   quad.pivot = quad.pivot or vec2()
   quad.current_frame = 1
   quad.fps = quad.fps or 24
   quad.loop = booleanOrDefaultBoolean(quad.loop, true)
   quad:addRect(0, 0, 0, 0)

   function quad.draw(self)
       if not self.timer or self.timer <= ElapsedTime then
           local anim = self.animations[self.current_animation]
           local frm = self.current_frame
           local uv = uvTexture(self.texture, self.tilesize.x, self.tilesize.y, anim[frm]).uv

           self:setRectTex(1, uv.x1, uv.y1, uv.w, uv.h)
           self.timer = ElapsedTime + 1 / self.fps
           self.current_frame = anim[frm + 1] and frm + 1 or 1

           if frm == #anim and not self.loop then
               self.current_frame = frm -- pull back
           end
       end

       pushStyle()
       noSmooth()
       pushMatrix()
       translate(self.position.x - self.pivot.x * self.spritesize.x, self.position.y - self.pivot.y * self.spritesize.y)
       self:setColors(self.tintcolor)
       self:setRect(1, self.spritesize.x/2, self.spritesize.y/2, self.spritesize.x, self.spritesize.y)
       _draw(self)
       popMatrix()
       popStyle()
   end

   return quad
end



Um unser Tutorial abzuschließen, hier noch ein letztes Beispiel, wie du die beiden Funktionen uvTexture und ssprite miteinander verwenden kannst:

function setup()
     mario = ssprite{
          texture = readImage("Dropbox:mario_spritesheet"),
          tilesize = vec2(16, 32), -- große jedes frame
          spritesize = vec2(64, 128), -- große auf dem bildschirm
          position = vec2(250, 400), -- x/y position auf dem bildschirm
          pivot = vec2(.5, .5) -- zentriere sprite auf bildschirm position
          animations = {
               walk = {
                    -- vec2(spalte, zeile) des tiles auf dem spritesheet
                        -- statt vec2 kann auch die tile nr. eingetragen werden!
                    vec2(1, 3), -- frame 1 (eigentlich tile nr.15)
                    vec2(2, 3), -- frame 2 (eigentlich tile nr.16)
                    vec2(3, 3), -- frame 3 (eigentlich tile nr.17)
                    vec2(4, 3), -- frame 4 (eigentlich tile nr.18)
                    33 -- frame 5 (eigentlich vec2(5, 4))
               }
          },
          current_animation = "walk",
          fps = 6,
          loop = true,
          tintcolor = color(250, 230, 180)
     }
end

function draw()
     mario:draw()
end



Sehr schön - Nun ist der Code kompakt und gut zu verstehen!

In diesem Post hast du hoffentlich etwas über Spritesheets und Animation-Techniken lernen können. Grundsätzlich ist das Animieren in Code keine große Kunst. Und wenn man es richtig angeht, spart man sich viel Aufwand im Nachgang.

Daher habe ich dir hier auch zwei wertvolle Funktionen vorgestellt. Du kannst sie studieren und es noch besser machen! Oder du ersparst dir zumindest die Arbeit, wenn du nicht die Zeit hast eine eigene Version zu programmieren.

Im nächsten Fallow-Up-Post werde ich dieses Thema noch einmal kurz aufgreifen, um dir die Möglichkeiten von uvTexture genauer erklären. Die Funktion ist wirklich ein Lebensretter, denn seit ich sie geschrieben habe musste ich nie wieder mit Textur-Koordinaten herumhantieren. Und das ist eine tolle Arbeitserleichterung!



Es gibt übrigens noch eine dritte Technik, mit der man Animationen abspielen kann. Sie ist jedoch etwas für erfahrene Programmierer, denn dabei geht es um das Rigging - auch eine 3D Technik bei der man Knochen an ein Mesh bindet und diese animiert. Ich habe hier bereits darüber gesprochen! Lese den Beitrag um mehr Hintergrund-Informationen über Spritesheets und Skeletal-Animation zu bekommen.