-
Notifications
You must be signed in to change notification settings - Fork 8
/
Copy pathAnimatedSprite.lua
512 lines (454 loc) · 16 KB
/
AnimatedSprite.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
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