--- Animation states using a spritesheet/imagetable. Ideal for use with `NobleSprite` objects. Suitable for other uses as well. -- @module Noble.Animation -- Noble.Animation = {} --- Setup -- @section setup --- Create a new animation "state machine". This function is called automatically when creating a new `NobleSprite`. -- @string __view This can be: the path to a spritesheet image file or an image table object (`Graphics.imagetable`). See Playdate SDK docs for imagetable file naming conventions. -- @return `animation`, a new animation object. -- @usage -- local myHero = MyHero("path/to/spritesheet") -- @usage -- -- When extending NobleSprite (recommended), you don't call Noble.Animation.new(), -- -- but you do feed its __view argument into MySprite.super.init()... -- MyHero = {} -- class("MyHero").extends(NobleSprite) -- -- function MyHero:init() -- MyHero.super.init(self, "assets/images/Hero") -- -- ... -- -- A new NobleSprite creates a Noble.Animation object named "self.animation" -- self.animation:addState("idle", 1, 30) -- self.animation:addState("jump", 31, 34, "float") -- self.animation:addState("float", 35, 45) -- self.animation:addState("turn", 46, 55, "idle") -- self.animation:addState("walk", 56, 65) -- -- ... -- end -- @usage -- local myAnimation = Noble.Animation.new("path/to/spritesheet") -- @usage -- -- When extending playdate.graphics.Sprite, Noble.Animation.new() must be called manually... -- MyHero = {} -- class("MyHero").extends(Graphics.sprite) -- -- function MyHero:init() -- MyHero.super.init(self) -- -- ... -- self.animation = Noble.Animation.new("assets/images/Hero") -- self.animation:addState("idle", 1, 30) -- self.animation:addState("jump", 31, 34, "float") -- self.animation:addState("float", 35, 45) -- self.animation:addState("turn", 46, 55, "idle") -- self.animation:addState("walk", 56, 65) -- -- ... -- end -- @see NobleSprite:init function Noble.Animation.new(__view) local animation = {} --- Properties -- @section properties --- The currently set animation state. -- -- This is intended as `read-only`. You should not modify this property directly. -- @see setState animation.current = nil --- The name of the current animation state. Calling this instead of `animation.current.name` is just a little faster. -- -- This is intended as `read-only`. You should not modify this property directly. animation.currentName = nil --- The current frame of the animation. This is the index of the imagetable, not the frame of the current state. -- -- Most of the time, you should not modify this directly, although you can if you're feeling saucy and are prepared for unpredictable results. -- @see draw animation.currentFrame = 1 --- This controls the flipping of the image when drawing. DIRECTION_RIGHT is unflipped, DIRECTION_LEFT is flipped on the X axis. -- @usage -- function MyHero:goLeft() -- self.animation.direction = Noble.Animation.DIRECTION_LEFT -- -- ... -- end animation.direction = Noble.Animation.DIRECTION_RIGHT --- This animation's spritesheet. You can replace this with another `playdate.graphics.imagetable` object, but generally you would not want to. -- @see new if (type(__view) == "userdata") then animation.imageTable = __view else animation.imageTable = Graphics.imagetable.new(__view) end -- The current count of frame durations. This is used to determine when to advance to the next frame. animation.frameDurationCount = 1 -- The previous number of frame durations in the animation animation.previousFrameDurationCount = 1 local empty = true --- Setup -- @section setup --- Add an animation state. The first state added will be the default set for this animation. -- -- NOTE: Added states are first-degree member objects of your Noble.Animation object, so do not use names of already existing methods/properties ("current", "draw", etc.). -- @string __name The name of the animation, this is also used as the key for the animation. -- @int __startFrame This is the first frame of this animation in the imagetable/spritesheet -- @int __endFrame This is the final frame of this animation in the imagetable/spritesheet -- @string[optional] __next By default, animation states will loop, but if you want to sequence an animation, enter the name of the next state here. -- @bool[opt=true] __loop If you want a state to "freeze" on its final frame, instead of looping, enter `false` here. -- @param[optional] __onComplete This function will run when this animation is complete. Be careful when using this on a looping animation! -- @int[opt=1] __frameDuration This is the number of ticks between each frame in this animation. If not specified, it will be set to 1. -- @usage -- -- You can reference an animation's state's properties using bog-standard lua syntax: -- -- animation.idle.startFrame -- 30 -- animation.walk.endFrame -- 65 -- animation.["walk"].endFrame -- 65 -- animation.jump.name -- "jump" -- animation.["jump"].next -- "float" -- animation.idle.next -- nil function animation:addState(__name, __startFrame, __endFrame, __next, __loop, __onComplete, __frameDuration) local loop = true local frameDuration = 1 if (__loop ~= nil) then loop = __loop end if (__frameDuration ~= nil) then frameDuration = __frameDuration end self[__name] = { name = __name, startFrame = __startFrame, endFrame = __endFrame, next = __next, loop = loop, onComplete = __onComplete, frameDuration = frameDuration, } -- Set this animation state as default if it is the first one added. if (empty == true) then empty = false self.currentFrame = __startFrame self.current = self[__name] self.currentName = __name self.frameDuration = frameDuration end end --- Methods -- @section methods --- Sets the current animation state. This can be run in a object's `update` method because it only changes the animation state if the new state is different from the current one. -- @tparam string|Noble.Animation __animationState The name of the animation to set. You can pass the name of the state, or the object itself. -- @bool[opt=false] __continuous Set to true if your new state's frames line up with the previous one's, i.e.: two walk cycles but one is wearing a cute hat! -- @tparam string|Noble.Animation __unlessThisState If this state is the current state, do not set the new one. -- @usage animation:setState("walk") -- @usage animation:setState(animation.walk) -- @usage -- animation:setState(animation.walkNoHat) -- -- -- animation:setState(animation.walkYesHat, true) -- @usage -- function MyHero:update() -- -- Input -- -- ... -- -- -- Physics/collisions -- -- ... -- -- -- Animation states -- if (grounded) then -- if (turning) then -- self.animation:setState(self.animation.turn) -- elseif (math.abs(self.velocity.x) > 15) then -- self.animation:setState(self.animation.walk, false, self.animation.turn) -- else -- self.animation:setState(self.animation.idle, false, self.animation.turn) -- end -- else -- self.animation:setState(self.animation.jump, false, self.animation.float) -- end -- -- groundedLastFrame = grounded -- end function animation:setState(__animationState, __continuous, __unlessThisState) if (__unlessThisState ~= nil) then if (type(__unlessThisState) == "string") then if (self.currentName == __unlessThisState) then return end elseif (type(__unlessThisState) == "table") then if (self.current == __unlessThisState) then return end end end local newState = nil if (type(__animationState) == "string") then if (self.currentName == __animationState) then return end newState = self[__animationState] self.currentName = __animationState elseif (type(__animationState) == "table") then if (self.current == __animationState) then return end newState = __animationState self.currentName = __animationState.name end local continuous = Utilities.handleOptionalBoolean(__continuous, false) if (continuous) then local localFrame = self.currentFrame - self.current.startFrame self.currentFrame = newState.startFrame + localFrame else self.currentFrame = newState.startFrame end self.current = newState end --- Draw the current frame. -- -- When attached to a NobleSprite, this is called by `NobleSprite:draw()` when added to a scene. For non-NobleSprite sprites, put this method inside your sprite's `draw()` method, or inside @{NobleScene:update|NobleScene:update}. -- @number[opt=0] __x -- @number[opt=0] __y -- @bool[opt=true] __advance Advances to the next frame after drawing this one. Noble.Animation is frame-based, not "delta time"-based, so its speed is dependent on your game's framerate. -- @usage -- function MySprite:draw() -- animation:draw() -- end -- @usage -- function MyScene:update() -- animation:draw(100,100) -- end function animation:draw(__x, __y, __advance) --print(self.currentName .. " > " .. self.currentFrame .. " >> " .. tostring(self.current.loop)) if (__advance == nil) then __advance = true end if (self.currentFrame < self.current.startFrame or self.currentFrame > self.current.endFrame + 1) then self.currentFrame = self.current.startFrame -- Error correction. elseif (self.currentFrame == self.current.endFrame + 1) then -- End frame behavior. if (self.current.next ~= nil) then self.currentFrame = self.current.next.startFrame -- Set to first frame of next animation. self.frameDurationCount = 1 -- Reset ticks. self.previousFrameDurationCount = self.frameDuration self:setState(self.current.next) -- Set next animation state. elseif (self.current.loop == true) then self.currentFrame = self.current .startFrame -- Loop animation state. (TO-DO: account for continuous somehow?) self.frameDurationCount = 1 -- Reset ticks. self.previousFrameDurationCount = self.frameDuration elseif (__advance) then self.currentFrame = self.currentFrame - 1 -- Undo advance frame because we want to draw the same frame again. end if (self.current.onComplete ~= nil) then self.current.onComplete() end end local x = __x or 0 local y = __y or 0 self.imageTable:drawImage(self.currentFrame, x, y, self.direction) if (__advance == true) then self.frameDurationCount += 1 if ((self.frameDurationCount - self.previousFrameDurationCount) >= self.current.frameDuration) then self.currentFrame = self.currentFrame + 1 self.previousFrameDurationCount += self.current.frameDuration end end --previousAnimationName = self.currentName end --- Sometimes, you just want to draw a specific frame. -- Use this for objects or sprites that you want to control outside of update loops, such as score counters, flipbook-style objects that respond to player input, etc. -- @int __frameNumber The frame to draw from the current state. This is not an imagetable index. Entering `1` will draw the selected state's `startFrame`. -- @string[opt=self.currentName] __stateName The specific state to pull the __frameNumber from. -- @number[opt=0] __x -- @number[opt=0] __y -- @param[opt=self.direction] __direction Override the current direction. function animation:drawFrame(__frameNumber, __stateName, __x, __y, __direction) local x = __x or 0 local y = __y or 0 local stateName = __stateName or self.currentName local direction = __direction or self.direction local frameNumber = self[stateName].startFrame - 1 + __frameNumber self.imageTable:drawImage(frameNumber, x, y, direction) end return animation end --- Constants -- @section constants --- A re-contextualized instance of `playdate.graphics.kImageUnflipped` Noble.Animation.DIRECTION_RIGHT = Graphics.kImageUnflipped --- A re-contextualized instance of `playdate.graphics.kImageFlippedX` Noble.Animation.DIRECTION_LEFT = Graphics.kImageFlippedX