帧组

让图像活起来

从像素到动画

处理

目前游戏是借助 Photoshop 搭建的。我这么做是考虑到想用一个简单的编辑器,根据 .psd 文件把一切转换进游戏。里面有我称为 ‘Scene’ 的东西,比如客厅。Photoshop 里的每个图层对应游戏里的一个 Frameset。

.psd 文件会用 NodeJs 和 Python 处理,变成单独的透明图片,并得到图片的轮廓(多边形)。然后这些图片再用 TexturePacker 处理,让游戏加载得更快。

这也叫 AtlasLoader,只是我会在 TexturePacker 生成的 json 里加入额外的数据,比如多边形信息。

{
  "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

扩展后的 atlas.json 里也写有 zindex,这是一个表示该图层在 Photoshop 中处于哪个位置的数字。这样所有图片都会按同样的顺序叠在一起。

zindex 比你想象的还要重要,因为它决定了 entity 是从物体前面还是后面走过。

它是一个表示图片在游戏中该放得多靠前或多靠后的数字,和 Photoshop 的图层顺序一致。

这部分我还得再重构一次,但目前它完全满足我的需求。如果玩家的脚和另一个 entity 处在同一高度,游戏里的 zindex 就会自动改变。我还会定义某个东西是 ‘static’ 的 zindex,还是要放在另一个 entity 的 ‘above’。

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

不过这篇文章其实是要讲 engine3 里的 Frameset 到底是什么。它是一个类,可以放入1张或多张图片。如果有多张,就可以像动画一样播放。一个 entity 可以有1个或多个 Frameset。关于图片的所有关键信息都在 Frameset 里,而且在游戏过程中也可以修改。

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
...

这个类现在已经有大约866行,太长了,不便贴在这里。它只包含控制图片的基础部分,无论是动画还是静态图片。

动画

当一个 frameset 有多张图片时,就可以依次播放。

frameset:play()
Martin 向右走

Martin 向右走