initial commit
This commit is contained in:
		
							
								
								
									
										512
									
								
								source/lib/AnimatedSprite.lua
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										512
									
								
								source/lib/AnimatedSprite.lua
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,512 @@ | ||||
| ----------------------------------------------- | ||||
| --- Sprite class extension with support of  --- | ||||
| --- imagetables and finite state machine,   --- | ||||
| --- with json configuration and autoplay.   --- | ||||
| ---            By @Whitebrim   git.brim.ml  --- | ||||
| ----------------------------------------------- | ||||
|  | ||||
| -- You can find examples and docs at https://github.com/Whitebrim/AnimatedSprite/wiki | ||||
| -- Comments use EmmyLua style | ||||
|  | ||||
| import 'CoreLibs/object' | ||||
| import 'CoreLibs/sprites' | ||||
| local gfx <const> = playdate.graphics | ||||
| local function emptyFunc()end | ||||
|  | ||||
| class("AnimatedSprite").extends(gfx.sprite) | ||||
|  | ||||
| ---@param imagetable table|string actual imagetable or path | ||||
| ---@param states? table If provided, calls `setStates(states)` after initialisation | ||||
| ---@param animate? boolean If `True`, then the animation of default state will start after initialisation. Default: `False` | ||||
| function AnimatedSprite.new(imagetable, states, animate) | ||||
| 	return AnimatedSprite(imagetable, states, animate) | ||||
| end | ||||
|  | ||||
| function AnimatedSprite:init(imagetable, states, animate) | ||||
| 	AnimatedSprite.super.init(self) | ||||
|  | ||||
| 	---@type table | ||||
| 	if (type(imagetable) == "string") then | ||||
| 		imagetable = gfx.imagetable.new(imagetable) | ||||
| 	end | ||||
| 	self.imagetable = imagetable | ||||
| 	assert(self.imagetable, "Imagetable is nil. Check if it was loaded correctly.") | ||||
|  | ||||
| 	self:add() | ||||
|  | ||||
| 	self.globalFlip = gfx.kImageUnflipped | ||||
| 	self.defaultState = "default" | ||||
| 	self.states = { | ||||
| 		default = { | ||||
| 			name = "default", | ||||
| 			---@type integer|string | ||||
| 			firstFrameIndex = 1, | ||||
| 			framesCount = #self.imagetable, | ||||
| 			animationStartingFrame = 1, | ||||
| 			tickStep = 1, | ||||
| 			frameStep = 1, | ||||
| 			reverse = false, | ||||
| 			---@type boolean|integer | ||||
| 			loop = true, | ||||
| 			yoyo = false, | ||||
| 			flip = gfx.kImageUnflipped, | ||||
| 			xScale = 1, | ||||
| 			yScale = 1, | ||||
| 			nextAnimation = nil, | ||||
|  | ||||
| 			onFrameChangedEvent = emptyFunc, | ||||
| 			onStateChangedEvent = emptyFunc, | ||||
| 			onLoopFinishedEvent = emptyFunc, | ||||
| 			onAnimationEndEvent = emptyFunc | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	self._enabled = false | ||||
| 	self._currentFrame = 0 -- purposely | ||||
| 	self._ticks = 1 | ||||
| 	self._previousTicks = 1 | ||||
| 	self._loopsFinished = 0 | ||||
| 	self._currentYoyoDirection = true | ||||
|  | ||||
| 	if (states) then | ||||
| 		self:setStates(states) | ||||
| 	end | ||||
|  | ||||
| 	if (animate) then | ||||
| 		self:playAnimation() | ||||
| 	end | ||||
| end | ||||
|  | ||||
| local function drawFrame(self) | ||||
| 	local state = self.states[self.currentState] | ||||
| 	self:setImage(self._image, state.flip ~ self.globalFlip, state.xScale, state.yScale) | ||||
| end | ||||
|  | ||||
| local function setImage(self) | ||||
| 	local frames = self.states[self.currentState].frames | ||||
| 	if (frames) then | ||||
| 		self._image = self.imagetable[frames[self._currentFrame]] | ||||
| 	else | ||||
| 		self._image = self.imagetable[self._currentFrame] | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Start/resume the animation   | ||||
| ---If `currentState` is nil then `defaultState` will be choosen as current | ||||
| function AnimatedSprite:playAnimation() | ||||
|  | ||||
| 	local state = self.states[self.currentState] | ||||
|  | ||||
| 	if (type(self.currentState) == 'nil') then | ||||
| 		self.currentState = self.defaultState | ||||
| 		state = self.states[self.currentState] | ||||
| 		self._currentFrame = state.animationStartingFrame + state.firstFrameIndex - 1 | ||||
| 	end | ||||
|  | ||||
| 	if (self._currentFrame == 0) then | ||||
| 		self._currentFrame = state.animationStartingFrame + state.firstFrameIndex - 1 | ||||
| 	end | ||||
|  | ||||
| 	self._enabled = true | ||||
| 	self._previousTicks = self._ticks | ||||
| 	setImage(self) | ||||
| 	drawFrame(self) | ||||
| 	if (state.framesCount == 1) then | ||||
| 		self._loopsFinished += 1 | ||||
| 		state.onFrameChangedEvent(self) | ||||
| 		state.onLoopFinishedEvent(self) | ||||
| 	else | ||||
| 		state.onFrameChangedEvent(self) | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Stop the animation without resetting | ||||
| function AnimatedSprite:pauseAnimation() | ||||
| 	self._enabled = false | ||||
| end | ||||
|  | ||||
| ---Play the animation without resetting | ||||
| function AnimatedSprite:resumeAnimation() | ||||
| 	self._enabled = true | ||||
| end | ||||
|  | ||||
| ---Play/Pause animation based on current state | ||||
| function AnimatedSprite:toggleAnimation() | ||||
| 	if (self._enabled) then | ||||
| 		self:pauseAnimation() | ||||
| 	else | ||||
| 		self:resumeAnimation() | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Stop and reset the animation   | ||||
| ---After calling `playAnimation` the animation will start from `defaultState`  | ||||
| function AnimatedSprite:stopAnimation() | ||||
| 	self:pauseAnimation() | ||||
| 	self.currentState = nil | ||||
| 	self._currentFrame = 0 -- purposely | ||||
| 	self._ticks = 1 | ||||
| 	self._previousTicks = self._ticks | ||||
| 	self._loopsFinished = 0 | ||||
| 	self._currentYoyoDirection = true | ||||
| end | ||||
|  | ||||
| local function addState(self, params) | ||||
| 	assert(params.name, "The animation state is unnamed!") | ||||
| 	if (self.defaultState == "default") then | ||||
| 		self.defaultState = params.name -- Init first added state as default | ||||
| 	end | ||||
|  | ||||
| 	self.states[params.name] = {} | ||||
| 	local state = self.states[params.name] | ||||
| 	setmetatable(state, {__index = self.states.default}) | ||||
|  | ||||
| 	params = params or {} | ||||
|  | ||||
| 	state.name = params.name | ||||
| 	if (params.frames ~= nil) then | ||||
| 		state["frames"] = params.frames -- Custom animation for non-sequential frames from the imagetable | ||||
| 		params.firstFrameIndex = 1 | ||||
| 		params.framesCount = #params.frames | ||||
| 	end | ||||
| 	if (type(params.firstFrameIndex) == "string") then | ||||
| 		local thatState = self.states[params.firstFrameIndex] | ||||
| 		state["firstFrameIndex"] = thatState.firstFrameIndex + thatState.framesCount | ||||
| 	else | ||||
| 		state["firstFrameIndex"] = params.firstFrameIndex -- index in the imagetable for the firstFrame | ||||
| 	end | ||||
| 	state["framesCount"] = params.framesCount and params.framesCount or (self.states.default.framesCount - state.firstFrameIndex + 1) -- This state frames count | ||||
| 	state["nextAnimation"] = params.nextAnimation -- Animation to switch to after this finishes | ||||
| 	if (params.nextAnimation == nil) then | ||||
| 		state["loop"] = params.loop -- You can put in number of loops or true for endless loop | ||||
| 	else | ||||
| 		state["loop"] = params.loop or false | ||||
| 	end | ||||
| 	state["reverse"] = params.reverse -- You can reverse animation sequence | ||||
| 	state["animationStartingFrame"] = params.animationStartingFrame or (state.reverse and state.framesCount or 1) -- Frame to start the animation from | ||||
| 	state["tickStep"] = params.tickStep -- Speed of animation (2 = every second frame) | ||||
| 	state["frameStep"] = params.frameStep -- Number of images to skip on next frame | ||||
| 	state["yoyo"] = params.yoyo -- Ping-pong animation (from 1 to n to 1 to n) | ||||
| 	state["flip"] = params.flip -- You can set up flip mode, read Playdate SDK Docs for more info | ||||
| 	state["xScale"] = params.xScale -- Optional scale for horizontal axis | ||||
| 	state["yScale"] = params.yScale -- Optional scale for vertical axis | ||||
|  | ||||
| 	state["onFrameChangedEvent"] = params.onFrameChangedEvent -- Event that will be raised when animation moves to the next frame | ||||
| 	state["onStateChangedEvent"] = params.onStateChangedEvent -- Event that will be raised when animation state changes | ||||
| 	state["onLoopFinishedEvent"] = params.onLoopFinishedEvent -- Event that will be raised when animation changes to the final frame | ||||
| 	state["onAnimationEndEvent"] = params.onAnimationEndEvent -- Event that will be raised after animation in this state ends | ||||
|  | ||||
| 	return state | ||||
| end | ||||
|  | ||||
| ---Parse `json` file with animation configuration | ||||
| ---@param path string Path to the file | ||||
| ---@return table config You can use it in `setStates(states)` | ||||
| function AnimatedSprite.loadStates(path) | ||||
| 	return assert(json.decodeFile(path), "Requested JSON parse failed. Path: " .. path) | ||||
| end | ||||
|  | ||||
| ---Get imagetable's frame index that is currently displayed | ||||
| ---@return integer index Current frame index | ||||
| function AnimatedSprite:getCurrentFrameIndex() | ||||
| 	if (self.currentState and self.states[self.currentState].frames) then | ||||
| 		return self.states[self.currentState].frames[self._currentFrame] | ||||
| 	else | ||||
| 		return self._currentFrame | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Get the current frame's local index in the state   | ||||
| ---I.e. 1, 2, 3, N, where N = number of frames in this state   | ||||
| ---Also works if `frames` property was provided | ||||
| ---@return integer index Current frame local index | ||||
| function AnimatedSprite:getCurrentFrameLocalIndex() | ||||
| 	return self.currentFrame - self.states[self.currentState].firstFrameIndex + 1 | ||||
| end | ||||
|  | ||||
| ---Get reference to the current state | ||||
| ---@return table state Reference to the current state | ||||
| function AnimatedSprite:getCurrentState() | ||||
| 	return self.states[self.currentState] | ||||
| end | ||||
|  | ||||
| ---Get reference to the current states | ||||
| ---@return table states Reference to the current states | ||||
| function AnimatedSprite:getLocalStates() | ||||
| 	return self.states | ||||
| end | ||||
|  | ||||
| ---Get copy of the states | ||||
| ---@return table states Deepcopy of the current states | ||||
| function AnimatedSprite:copyLocalStates() | ||||
| 	return table.deepcopy(self.states) | ||||
| end | ||||
|  | ||||
| ---Add all states from the `states` to the current state machine (overwrites values in case of conflicts) | ||||
| ---@param states table State machine state list, you can get one by calling `loadStates` | ||||
| ---@param animate? boolean If `True`, then the animation of default/current state will start immediately after. Default: `False` | ||||
| ---@param defaultState? string If provided, changes default state | ||||
| function AnimatedSprite:setStates(states, animate, defaultState) | ||||
| 	local statesCount = #states | ||||
|  | ||||
| 	local function proceedState(state) | ||||
| 		if (state.name ~= "default") then | ||||
| 			addState(self, state) | ||||
| 		else | ||||
| 			local default = self.states.default | ||||
| 			for key, value in pairs(state) do | ||||
| 				default[key] = value | ||||
| 			end | ||||
| 		end | ||||
| 	end | ||||
|  | ||||
| 	if (statesCount == 0) then | ||||
| 		proceedState(states) | ||||
| 		if (defaultState) then | ||||
| 			self.defaultState = defaultState | ||||
| 		end | ||||
| 		if (animate) then | ||||
| 			self:playAnimation() | ||||
| 		end | ||||
| 		return | ||||
| 	end | ||||
|  | ||||
| 	for i = 1, statesCount do | ||||
| 		proceedState(states[i]) | ||||
| 	end | ||||
| 	if (defaultState) then | ||||
| 		self.defaultState = defaultState | ||||
| 	end | ||||
| 	if (animate) then | ||||
| 		self:playAnimation() | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Add new state to the state machine | ||||
| ---@param name string Name of the state, should be unique, used as id | ||||
| ---@param startFrame? integer Index of the first frame in the imagetable (starts from 1). Default: `1` (from states.default) | ||||
| ---@param endFrame? integer Index of the last frame in the imagetable. Default: last frame (from states.default) | ||||
| ---@param params? table See examples | ||||
| ---@param animate? boolean If `True`, then the animation of this state will start immediately after. Default: `False` | ||||
| function AnimatedSprite:addState(name, startFrame, endFrame, params, animate) | ||||
| 	params = params or {} | ||||
| 	params.firstFrameIndex = startFrame or 1 | ||||
| 	params.framesCount = endFrame and (endFrame - params.firstFrameIndex + 1) or nil | ||||
| 	params.name = name | ||||
|  | ||||
| 	addState(self, params) | ||||
|  | ||||
| 	if (animate) then | ||||
| 		self.currentState = name | ||||
| 		self:playAnimation() | ||||
| 	end | ||||
|  | ||||
| 	return { | ||||
| 		asDefault = function () | ||||
| 			self.defaultState = name | ||||
| 		end | ||||
| 	} | ||||
| end | ||||
|  | ||||
| ---Change current state to an existing state | ||||
| ---@param name string New state name | ||||
| ---@param play? boolean If new animation should be played right away. Default: `True` | ||||
| function AnimatedSprite:changeState(name, play) | ||||
| 	if (name == self.currentState) then | ||||
| 		return | ||||
| 	end | ||||
| 	play = type(play) == "nil" and true or play | ||||
| 	local state = self.states[name] | ||||
| 	assert (state, "There's no state named \""..name.."\".") | ||||
| 	self.currentState = name | ||||
| 	self._currentFrame = 0 -- purposely | ||||
| 	self._loopsFinished = 0 | ||||
| 	self._currentYoyoDirection = true | ||||
| 	state.onStateChangedEvent(self) | ||||
| 	if (play) then | ||||
| 		self:playAnimation() | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Change current state to an existing state and start from selected frame   | ||||
| ---If new state is the same as current state, nothing will change | ||||
| ---@param name string New state name | ||||
| ---@param frameIndex integer Local frame index of this state. Indexing starts from 1. Default: `1` | ||||
| ---@param play? boolean If new animation should be played right away. Default: `True` | ||||
| function AnimatedSprite:changeStateAndSelectFrame(name, frameIndex, play) | ||||
| 	if (name == self.currentState) then | ||||
| 		return | ||||
| 	end | ||||
| 	play = type(play) == "nil" and true or play | ||||
| 	frameIndex = frameIndex or 1 | ||||
| 	local state = self.states[name] | ||||
| 	assert (state, "There's no state named \""..name.."\".") | ||||
| 	self.currentState = name | ||||
| 	self._currentFrame = state.firstFrameIndex + frameIndex - 1 | ||||
| 	self._loopsFinished = 0 | ||||
| 	self._currentYoyoDirection = true | ||||
| 	state.onStateChangedEvent(self) | ||||
| 	if (play) then | ||||
| 		self:playAnimation() | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Force animation state machine to switch to the next state | ||||
| ---@param instant? boolean If `False` change will be performed after the final frame of this loop iteration. Default: `True` | ||||
| ---@param state? string Name of the state to change to. If not provided, animator will try to change to the next animation, else stop the animation | ||||
| function AnimatedSprite:forceNextAnimation(instant, state) | ||||
| 	instant = type(instant) == "nil" and true or instant | ||||
| 	local currentState = self.states[self.currentState] | ||||
| 	self.forcedState = state | ||||
|  | ||||
| 	if (instant) then | ||||
| 		self.forcedSwitchOnLoop = nil | ||||
| 		currentState.onAnimationEndEvent(self) | ||||
| 		if (currentState.name == self.currentState) then -- If state was not changed during the event then proceed | ||||
| 			if (type(self.forcedState) == "string") then | ||||
| 				self:changeState(self.forcedState) | ||||
| 				self.forcedState = nil | ||||
| 			elseif (currentState.nextAnimation) then | ||||
| 				self:changeState(currentState.nextAnimation) | ||||
| 			else | ||||
| 				self:stopAnimation() | ||||
| 			end | ||||
| 		end | ||||
| 	else | ||||
| 		self.forcedSwitchOnLoop = self._loopsFinished + 1 | ||||
| 	end | ||||
| end | ||||
|  | ||||
| ---Set default state | ||||
| ---@param name string Name of an existing state | ||||
| function AnimatedSprite:setDefaultState(name) | ||||
| 	assert (self.states[name], "State name is nil.") | ||||
| 	self.defaultState = name | ||||
| end | ||||
|  | ||||
| ---Print all states from this state machine to the console for debug purposes | ||||
| function AnimatedSprite:printAllStates() | ||||
| 	printTable(self.states) | ||||
| end | ||||
|  | ||||
| ---Procees the animation to the next step without redrawing the sprite | ||||
| local function processAnimation(self) | ||||
| 	local state = self.states[self.currentState] | ||||
|  | ||||
| 	local function changeFrame(value) | ||||
| 		value += state.firstFrameIndex | ||||
| 		self._currentFrame = value | ||||
| 		state.onFrameChangedEvent(self) | ||||
| 	end | ||||
|  | ||||
| 	local reverse = state.reverse | ||||
| 	local frame = self._currentFrame - state.firstFrameIndex | ||||
| 	local framesCount = state.framesCount | ||||
| 	local frameStep = state.frameStep | ||||
|  | ||||
| 	if (self._currentFrame == 0) then -- true only after changing state | ||||
| 		self._currentFrame = state.animationStartingFrame + state.firstFrameIndex - 1 | ||||
| 		if (framesCount == 1) then | ||||
| 			self._loopsFinished += 1 | ||||
| 			state.onFrameChangedEvent(self) | ||||
| 			state.onLoopFinishedEvent(self) | ||||
| 			return | ||||
| 		else | ||||
| 			state.onFrameChangedEvent(self) | ||||
| 		end | ||||
| 		setImage(self) | ||||
| 		return | ||||
| 	end | ||||
|  | ||||
| 	if (framesCount == 1) then -- if this state is only 1 frame long | ||||
| 		self._loopsFinished += 1 | ||||
| 		state.onFrameChangedEvent(self) | ||||
| 		state.onLoopFinishedEvent(self) | ||||
| 		return | ||||
| 	end | ||||
|  | ||||
| 	if (state.yoyo) then | ||||
| 		if (reverse ~= self._currentYoyoDirection) then | ||||
| 			if (frame + frameStep + 1 < framesCount) then | ||||
| 				changeFrame(frame + frameStep) | ||||
| 			else | ||||
| 				if (frame ~= framesCount - 1) then | ||||
| 					self._loopsFinished += 1 | ||||
| 					changeFrame(2 * framesCount - frame - frameStep - 2) | ||||
| 					state.onLoopFinishedEvent(self) | ||||
| 				else | ||||
| 					changeFrame(2 * framesCount - frame - frameStep - 2) | ||||
| 				end | ||||
| 				self._currentYoyoDirection = not self._currentYoyoDirection | ||||
| 			end | ||||
| 		else | ||||
| 			if (frame - frameStep > 0) then | ||||
| 				changeFrame(frame - frameStep) | ||||
| 			else | ||||
| 				if (frame ~= 0) then | ||||
| 					self._loopsFinished += 1 | ||||
| 					changeFrame(frameStep - frame) | ||||
| 					state.onLoopFinishedEvent(self) | ||||
| 				else | ||||
| 					changeFrame(frameStep - frame) | ||||
| 				end | ||||
| 				self._currentYoyoDirection = not self._currentYoyoDirection | ||||
| 			end | ||||
| 		end | ||||
| 	else | ||||
| 		if (reverse) then | ||||
| 			if (frame - frameStep > 0) then | ||||
| 				changeFrame(frame - frameStep) | ||||
| 			else | ||||
| 				if (frame ~= 0) then | ||||
| 					self._loopsFinished += 1 | ||||
| 					changeFrame((frame - frameStep) % framesCount) | ||||
| 					state.onLoopFinishedEvent(self) | ||||
| 				else | ||||
| 					changeFrame((frame - frameStep) % framesCount) | ||||
| 				end | ||||
| 			end | ||||
| 		else | ||||
| 			if (frame + frameStep + 1 < framesCount) then | ||||
| 				changeFrame(frame + frameStep) | ||||
| 			else | ||||
| 				if (frame ~= framesCount - 1) then | ||||
| 					self._loopsFinished += 1 | ||||
| 					changeFrame((frame + frameStep) % framesCount) | ||||
| 					state.onLoopFinishedEvent(self) | ||||
| 				else | ||||
| 					changeFrame((frame + frameStep) % framesCount) | ||||
| 				end | ||||
| 			end | ||||
| 		end | ||||
| 	end | ||||
|  | ||||
| 	setImage(self) | ||||
| end | ||||
|  | ||||
| ---Called by default in the `:update()` function.   | ||||
| ---Must be called once per frame if you overwrite `:update()`.   | ||||
| ---Invoke manually to move the animation to the next frame. | ||||
| function AnimatedSprite:updateAnimation() | ||||
| 	if (self._enabled) then | ||||
| 		self._ticks += 1 | ||||
| 		if ((self._ticks - self._previousTicks) >= self.states[self.currentState].tickStep) then | ||||
| 			local state = self.states[self.currentState] | ||||
| 			local loop = state.loop | ||||
| 			local loopsFinished = self._loopsFinished | ||||
| 			if (type(loop) == "number" and loop <= loopsFinished or | ||||
| 				type(loop) == "boolean" and not loop and loopsFinished >= 1 or | ||||
| 				self.forcedSwitchOnLoop == loopsFinished) then | ||||
| 				self:forceNextAnimation(true) | ||||
| 				return | ||||
| 			end | ||||
| 			processAnimation(self) | ||||
| 			drawFrame(self) | ||||
| 			self._previousTicks += state.tickStep | ||||
| 		end | ||||
| 	end | ||||
| end | ||||
|  | ||||
| function AnimatedSprite:update() | ||||
| 	self:updateAnimation() | ||||
| end | ||||
		Reference in New Issue
	
	Block a user