Frameset

Bilder zum Leben erwecken

Vom Pixel zur Animation

Verarbeitung

Im Moment ist das Spiel mit Hilfe von Photoshop aufgebaut. Dabei hatte ich im Hinterkopf, dass ich einen einfachen Editor verwenden will, der anhand der .psd-Datei alles fürs Spiel umwandelt. Darin steht das, was ich eine ‘Scene’ nenne, zum Beispiel das Wohnzimmer. Jede Ebene in Photoshop entspricht einem Frameset im Spiel.

Die .psd-Datei wird mit NodeJs und Python verarbeitet, sodass daraus einzelne transparente Bilder werden und ich die Konturen der Bilder habe (Polygon). Die Bilder werden danach mit TexturePacker verarbeitet, damit sie im Spiel schnell geladen werden.

Man nennt das auch einen AtlasLoader, nur ergänze ich das json, das TexturePacker erstellt, um zusätzliche Daten wie die Polygon-Infos.

{
  "meta": {
    "ukey": 20,
    "height": 240,
    "width": 800,
    "pathFindingTop": 140,
    "audiosets": [
      "assets/audio/character/11/en/audio.mp"
    ],
    "image": "assets/scene/20/atlas.png"
  },
  "layers": [
    {
      "name": "background-stone-circle",
      "frames": [
        {
          "visible": true,
          "quad": {
            "x": 1596,
            "y": 369,
            "w": 198,
            "h": 105
          },
          "x": 82,
          "y": 59,
          "order": 1,
          "collidable": []
        }
      ],
      "collidableA": [
        1,
        2,
        3,
        4
      ],
      "collidableB": [
        4
      ],
      "collidableRect": [
        82,
        59,
        279,
        163
      ],
      "zindexStatic": 13,
      "zindexType": "static"
    },
...

Zindex

In der Erweiterung von atlas.json steht auch der zindex, eine Zahl, die angibt, an welcher Position die Ebene in Photoshop liegt. So liegen die Bilder alle in derselben Reihenfolge übereinander.

Der zindex ist noch wichtiger, als man denkt, denn er bestimmt, ob eine Entity vor oder hinter einem Objekt entlangläuft.

Das ist eine Zahl dafür, wie weit vorne oder hinten das Bild im Spiel liegen muss, genau wie die Ebenenreihenfolge in Photoshop.

Ich muss das noch einmal refaktorieren, aber es tut genau, was ich im Moment will. Wenn die Füße des Spielers auf derselben Höhe stehen wie die einer anderen Entity, ändert sich der zindex im Spiel automatisch. Außerdem lege ich fest, ob etwas einen ‘static’ zindex hat oder ‘above’ einer anderen Entity liegen soll.

function World:updateZindex()
    local framesetSortByY = {}
    for i = 1, #self.framesetsToProcess do
        if self.framesetsToProcess[i] and self.framesetsToProcess[i].parentEntity then
            local y = self.framesetsToProcess[i]:getY() or self.framesetsToProcess[i]:getFrameY()

            if self.framesetsToProcess[i]:getOriginY() ~= Offset.BOTTOM then
                -- @warning we use the max height, not the current frame height, but it is oke
                y = y + self.framesetsToProcess[i]:calcFrameMaxHeight()
            end

            -- special cases
            if self.framesetsToProcess[i].zindexType == "static" then
                local zindexStatic = self.framesetsToProcess[i].zindexStatic
                if zindexStatic ~= nil then
                    -- force the static zindex to be the given number, don't change it
                    y = tonumber(zindexStatic)
                else
                    -- @warning we assume that the zindex are in correct order inside the yml files
                    y = i
                end
            end

            -- temp processing table
            table.insert(
                framesetSortByY,
                {
                    frameset = self.framesetsToProcess[i],
                    y = y,
                    ukey = self.framesetsToProcess[i].parentEntity.ukey
                }
            )
        end
    end

    -- special cases, after processing
    for i = 1, #framesetSortByY do
        if framesetSortByY[i].frameset.zindexType == "above" then
            -- search this frameset
            for i2 = 1, #framesetSortByY do
                if framesetSortByY[i].frameset.zindexStatic == framesetSortByY[i2].ukey then
                    framesetSortByY[i].y = framesetSortByY[i2].y + 1
                    break
                end
            end
        end
    end

    -- sort using the calculate y
    table.sort(
        framesetSortByY,
        function(a, b)
            return a.y ~= nil and b.y ~= nil and a.y < b.y
        end
    )

    -- now we loop over the this sorted by y and create the new zindex
    for i = 1, #framesetSortByY do
        framesetSortByY[i].frameset.zindex = i
    end

    -- now we let the engine do his thing with zindex sorting
    World.super.updateZindex(self)
end

Frameset

Aber dieser Artikel sollte eigentlich davon handeln, was ein Frameset in engine3 genau ist. Es ist eine Klasse, in die man 1 oder mehrere Bilder packen kann. Wenn es mehrere sind, kann man sie wie eine Animation abspielen. Eine Entity kann 1 oder mehrere Framesets haben. Alle wesentlichen Informationen über das Bild stehen im Frameset und lassen sich auch während des Spiels ändern.

Ein knapper Überblick über die Klasse Frameset

---@class Frameset: Object
---@field parentEntity Object The parent entity that going to use this frameset
---@field namespace string For example scene/item/character
---@field name string
---@field visible boolean
---@field config Config
---@field resource love.Image resource A reference to the atlas image
---@field resourceFile string resourceFile Resource path
---@field frames table<Frame> frames A static image contains only one frame
---@field framekey number Current frame key, a non animation have only one 1 frame. (default 1)
---@field isAnimation boolean It is counting the number of frames > 1
---@field zindex integer The z-index order
---@field scaleX? number Scale. A negative scaling factor to flip about its centerline.
---@field scaleY? number Scale. A negative scaling factor to flip about its centerline.
---@field quad? table<love.Quad> quad Default not loaded
---@field x? integer The x position for the full frameset, to overrule the default Frame.x
---@field y? integer The y position for the full frameset, to overrule the default Frame.y
---@field frameMaxWidth? integer The max width from all the frames in a frameset this is only set after calling self:calcFrameMaxWidth()
---@field frameMaxHeight? integer The max height from all the frames in a frameset this is only set after calling self:calcFrameMaxHeight()
---@field frameMaxWidthObj Frame The frame from all the frames in a frameset this is only set after calling self:calcFrameMaxWidth()
---@field frameMaxHeightObj? Frame The frame from all the frames in a frameset this is only set after calling self:calcFrameMaxHeight()
---@field time? integer Animation timer
---@field animationStatus? number See constants IDLE|PLAYING|PAUSE
---@field callbackAfterDraw? table<function> Table with 0 or more callback functions.
---@field callbackAfterUpdate? table<function> Table with 0 or more callback functions.
---@field callbackAfterXy? table<function> Table with 0 or more callback functions.
---@field callbackAfterVisibility? table<function> Table with 0 or more callback functions.
---@field collidableA? table If this frameset is collidable (sides) {1[opt](left),2([opt]right),3([opt]top),4([opt]bottom)}.
---@field collidableB? table If this frameset is collidable (sides) overrule the other collidable target option {1[opt](left),2([opt]right),3([opt]top),4([opt]bottom)}.
---@field collidableRects? table Collidable rects [{x1,y1,x2,y2}, ...]
---@field whitespace? boolean If the source image uses a whitespace around its image
---@field originX? integer Default the x coordinate origin is Offset.LEFT. For example when you use center, the x position is in the frameset center
---@field originY? integer
---@field callback Callback

...
function Frameset:getOriginY()
    return self.originY
end

function Frameset:getOriginX()
    return self.originX
end

function Frameset:getZindex()
    return self.zindex
end

function Frameset:getFrameX()
    local currentFrame = self:getCurrentFrame()
    return currentFrame.x
end

function Frameset:getFrameX()
    local currentFrame = self:getCurrentFrame()
    return currentFrame.x
end

function Frameset:getX()
    return self.x
end

function Frameset:getFrameY()
    local currentFrame = self:getCurrentFrame()
    return currentFrame.y
end

function Frameset:getY()
    return self.y
end

function Frameset:stop()
    self.animationStatus = self.IDLE
    self.framekey = 1
    self:hide()
end

--- Start the frameset animation.
-- When you start a animation it will visible.
-- time to show, framekey
-- {
--  {0.00, 1},
--  {0.46, 2},
--  {0.95, 5},
--  ...
-- }
---@param timeTable? table Play the tables like the table
---@param timeTableEndCallback? table When the last key is played
---@param ignoreAutoShow? bool Don't execute the show() function
function Frameset:play(timeTable, timeTableEndCallback, ignoreAutoShow)
    if timeTable then
        self.timeTable = timeTable
        for _, date in pairs(timeTable) do
            Tick.delay(
                function()
                    self.framekey = date[2]

                    -- the last key
                    if self.timeTable[#self.timeTable][1] == date[1] then
                        if timeTableEndCallback then
                            timeTableEndCallback()
                        end
                    end
                end,
                date[1]
            )
        end
    end
    self.animationStatus = self.PLAYING
    if ignoreAutoShow ~= true then
        self:show()
    end
end

function Frameset:pause(frameKey)
    if frameKey ~= nil then
        self.framekey = frameKey
        self.frames[self.framekey].visible = true
    end

    self.animationStatus = self.PAUSE
end

function Frameset:update(dt, entity)
    local currentFrame = self:getCurrentFrame()
    local update = true

    -- its not not visible on the screen
    if currentFrame.visible == false then
        update = false
    end

    -- its a static image, no need to update things
    if self.isAnimation == false then
        update = false
    end

    -- We don't want to play this animation
    if self.isAnimation and self.animationStatus ~= self.PLAYING then
        update = false
    end

    -- We manage our own frame time
    if self.timeTable ~= nil then
        update = false
    end

    if update then
        self.time = self.time + dt
        if self.time >= (currentFrame.duration / 1000) then
            self.framekey = (self.framekey % #self.frames) + 1
            self.time = 0
        end
    end

    self:callback(self.callbackAfterUpdate, entity, self, update, currentFrame, dt)

    return update
end
...

Die Klasse hat inzwischen etwa 866 Zeilen und ist zu lang, um sie hier zu zeigen. Sie enthält nur die Grundlage für die Steuerung der Bilder, sowohl animiert als auch statisch.

Animation

Wenn ein Frameset mehrere Bilder hat, kann man sie nacheinander abspielen.

frameset:play()
Martin läuft nach rechts

Martin läuft nach rechts