I love how simple and useful it is.
Really nice function!
Here's a function I wrote to draw an image tiled within bounds with an optional offset. The built-in image:drawTiled(...)
does not take a draw offset, so the tiled image is always tiled from 0, 0 of the destination rect. Ehh, it appears to work!
function drawTiledImage(img, bounds, offset_x, offset_y)
offset_x = offset_x or 0
offset_y = offset_y or 0
-- Take easy route when no offset is specified.
if offset_x == 0 and offset_y == 0 then
img:drawTiled(bounds)
return
end
local iw, ih = img:getSize()
local sx = math.abs(offset_x % iw) - iw + bounds.x
local sy = math.abs(offset_y % ih) - ih + bounds.y
local cx, cy, cw, ch = playdate.graphics.getClipRect()
playdate.graphics.setClipRect(bounds)
img:drawTiled(sx, sy, bounds.width - sx, bounds.height - sy)
playdate.graphics.setClipRect(cx, cy, cw, ch)
end
Converted the Roomy scene manager lib over to be useable with playdate
::edit::
Finally added a read me
Here's a simple class I made to make a scrolling parallax background simple
local pd <const> = playdate
local gfx <const> = pd.graphics
class("Parallax").extends(gfx.sprite)
function Parallax:init()
Parallax.super.init(self)
self.layers = {}
end
function Parallax:draw(...)
gfx.setClipRect(...)
for _, layer in ipairs(self.layers) do
local img = layer.image
-- lock offset to steps of 2 to reduce flashing
local offset = layer.offset - (layer.offset % 2)
local w = layer.width
img:draw(self.x+offset, self.y)
if offset < 0 or offset > w - self.width then
if offset > 0 then
img:draw(self.x+offset-w, self.y)
else
img:draw(self.x+offset+w, self.y)
end
end
end
gfx.clearClipRect()
end
function Parallax:addLayer(img, depth)
local w, _ = img:getSize()
local layer = {}
layer.image = img
layer.depth = depth
layer.offset = 0
layer.width = w
table.push(self.layers, layer)
end
function Parallax:scroll(delta)
for _, layer in ipairs(self.layers) do
layer.offset = math.ring(
layer.offset + (delta * layer.depth),
-layer.width, 0
)
end
self:markDirty()
end
it also uses the math.ring
function that was posted is this thread somewhere (I can't find it atm)
function math.ring(a, min, max)
if min > max then
min, max = max, min
end
return min + (a-min)%(max-min)
end
It's simple to use:
img_1 = gfx.image.new("images/parallax-1")
img_2 = gfx.image.new("images/parallax-2")
-- create
local parallax = Parallax()
parallax:setSize(400,240)
parallax:addLayer(img_1, 0.2)
parallax:addLayer(img_2, 0.6)
parallax:add()
-- scroll
parallax:scroll(10)
Pulled code from Lua SDK to read cranks ticks and wrapped it in an object. The reason for this is that the SDK code uses a local global vars which can be an issue if you want to read the number of ticks, or even with a different ticksPerRevolution
value. Each reading resets the sampler.
So, with this code you simply create a Ticker, and sample each frame or whatever. You can reset it too during periods where you don't need samples.
You can of course create multiple Tickers each with their own ticksPerRevolution values to sample at different rates.
local ticker = Ticker.new(20)
-- elsewhere:
function update()
local ticks = ticker:sample()
end
Anyway, I've found it helpful so thought you may too.
Ticker = {}
Ticker.__index = Ticker
function Ticker.new(ticks_per_revolution)
local ticker = {}
setmetatable(ticker, Ticker)
ticker.ticks_per_revolution = ticks_per_revolution
ticker.last_reading = nil
return ticker
end
function Ticker:setTicksPerRevolution(ticks_per_revolution)
self.ticks_per_revolution = ticks_per_revolution
end
function Ticker:reset()
self.last_reading = nil
end
function Ticker:sample()
local totalSegments = self.ticks_per_revolution
local degreesPerSegment = 360 / self.ticks_per_revolution
local thisCrankReading = playdate.getCrankPosition()
local lastCrankReading = self.last_reading
if lastCrankReading == nil then
lastCrankReading = thisCrankReading
end
-- if it seems we've gone more than halfway around the circle, that probably means we're seeing:
-- 1) a reversal in directiotn, not that the player is really cranking that fast. (a good assumption if fps is 20 or higher; maybe not as good if we're at 2 fps or similar.)
-- 2) a crossing of the 359->0 border, which gives the appearance of a massive crank change, but is really very small.
-- both these cases can be treated identically.
local difference = thisCrankReading - lastCrankReading
if difference > 180 or difference < -180 then
if lastCrankReading >= 180 then
-- move tick_lastCrankReading back 360 degrees so it's < 0. It's the same location, just it is unequivocally lower than thisCrankReading
lastCrankReading -= 360
else
-- move tick_lastCrankReading ahead 360 degrees so it's > 0. It's the same location, just now it is unequivocally greater than thisCrankReading.
lastCrankReading += 360
end
end
-- which segment is thisCrankReading in?
local thisSegment = math.ceil(thisCrankReading / degreesPerSegment)
local lastSegment = math.ceil(lastCrankReading / degreesPerSegment)
local segmentBoundariesCrossed = thisSegment - lastSegment
-- save off value
self.last_reading = thisCrankReading
return segmentBoundariesCrossed
end
Determine tilt angle from reference point using accelerometer.
Spent a bit of time trying to figure this out so thought I'd just share here. If you want to determine the angle a player has tilted the device vertically (along the Y), I've so far found this to work pretty well:
-- Store reference position which to measure tilt from.
local _, ay, az = playdate.readAccelerometer()
local start_v = geometry.vector2D.new(ay, az)
-- Elsewhere, measure angle off reference position
local _, ay, az = playdate.readAccelerometer()
local current_v = geometry.vectory2D.new(ay, az)
local tilt_angle = start_v:angleBetween(current_v)
I think that's it. Though I haven't tried it, it seems you'd be able to measure tilt along the X axis by replacing ay with ax (the first value off readAccelerometer that I'm ignoring in this example).
OK! Happy, um, accelerating?
Cheat Codes
I've been working on adding some cheats to my game, figured I'd share the class here incase anyone else would like it.
local keys = {
a = playdate.kButtonA,
b = playdate.kButtonB,
up = playdate.kButtonUp,
down = playdate.kButtonDown,
left = playdate.kButtonLeft,
right = playdate.kButtonRight
}
class("CheatCode").extends()
function CheatCode:init(...)
local seq = {}
for _, key in ipairs({...}) do
local v = keys[key]
assert(v, "CheatCode: unknown key given => "..tostring(key))
table.insert(seq, v)
end
self._seq = seq
self.progress = 1
self.completed = false
self.run_once = true
self:setTimerDelay(400)
end
function CheatCode:update()
-- exit early if complete
if self.run_once and self.completed then return end
local _, pressed, _ = playdate.getButtonState()
-- exit early if no button currently pressed
if pressed == 0 then return end
if pressed == self._seq[self.progress] then
self.progress += 1
self._timer:reset()
if self.progress > #self._seq then
self.completed = true
if type(self.onComplete) == "function" then
self.onComplete()
end
end
else
self:reset()
end
end
function CheatCode:reset()
self.progress = 1
self._timer:reset()
self._timer:pause()
end
function CheatCode:setTimerDelay(ms)
if self._timer then
self._timer:remove()
end
self._timer = playdate.timer.new(ms, function() self:reset() end)
self._timer:pause()
self._timer.discardOnCompletion = false
end
function CheatCode:nextIs(key)
return keys[key] == self._seq[self.progress]
end
Using it is simple enough
-- initialize
local cheat = CheatCode("up", "up", "down", "down", "left", "right", "left", "right", "b", "a")
cheat.onComplete = function() print("cheat") end
-- in update function
playdate.timer.updateTimers() -- uses timers so make sure you call this
cheat:update()
By default it'll only trigger the first time the sequence is called, can be changed by setting run_once
to false
CheatCode:nextIs([key])
is a helper to allow you to avoid triggering other effects while the code is being entered. eg:
if not cheat:nextIs("a") then
-- do something else
end
This is very helpful! Have you put this in a github repo with some documentation by any chance?
@dustin , I am quite interested in using this model as it could make reusing some cutscenes or parts of them quite easy (probably in conjunction with the Sequence lib also posted on the page here).
Nonetheless, could you clarify where does the dt param come from in your example? I assume it means time difference, but I am a bit confused by it.
I am hoping to achieve nicer animations on cutscenes with the use of these 2 libs, but please let me know if there is an easier way
Thanks for the help
The easiest way to calculate the time delta between frames is just to assume it's the current frame rate. You could just use:
local dt <const> = 1.0 / playdate.display.getRefreshRate()
Or if you want to be more accurate, you could do something like:
local previous_time = nil
function playdate.update()
local dt = 0
local current_time <const> = playdate.getCurrentTimeMilliseconds()
if previous_time ~= nil then
dt = (current_time - previous_time) / 1000.0
end
previous_time = current_time
if dt == 0 then
return
end
end
Simple just works object pooling function.
---Add objectToAdd into list
---@param list table table list
---@param objectToAdd table needs a "enabled" key
function ObjectPooling(list, objectToAdd)
local found = false
for i = 1, #list, 1 do
local o = list[i]
if not o.enabled then
list[i] = objectToAdd
found = true
break
end
end
if not found then
list[#list+1] = objectToAdd
end
end
Super simple function to shake the camera multiple times, defined by _numberOfShakes
, waiting _delayBetweenShakes
milliseconds between each shake. The amount the camera moves each shake is a random number of pixels between -_offsetAmount
and _offsetAmount
.
Example - When my player takes damage, I just call this function:
cameraShake(20,15,3)
Note: This does use the PlaydateSDK Timer, so remember to call playdate.timer.updateTimers()
in your playdate.update()
function.
function cameraShake(_numberOfShakes,_delayBetweenShakes,_offsetAmount)
-- do some shakes
for i = 1, _numberOfShakes do
playdate.timer.performAfterDelay(i*_delayBetweenShakes, function()
playdate.graphics.setDrawOffset(math.random(-_offsetAmount,_offsetAmount),math.random(-_offsetAmount,_offsetAmount))
end)
end
-- reset camera
playdate.timer.performAfterDelay(_numberOfShakes*_delayBetweenShakes+_delayBetweenShakes, function()
playdate.graphics.setDrawOffset(0,0)
end)
end
There was a post about this earlier in this thread but since we have some updates regarding the project, I'll link to the announcement thread here too.
A lot of the code posted here could greatly benefit being shared via a toybox which makes is easy to install, maintain and update. Even single methods can be shared as single file toyboxes providing very targeted functionality but allowing everyone to easily use or contirbute to the toybox.
It would be wonderful to have this wealth of libs we can all use to get any project up and running quicker.
More info: (re?)-Introducing toybox.py - A Dependency Manager for the Playdate
If you need help creating or using a toybox for your stuff, please reach out directly to me and I will help you out.
Not sure where I picked this function up at or if I wrote it, but I find myself using it all the time. It's very useful to scale a number across two different systems. For instance, if you have a number X
that has values ranging from 100 to 500 (x_min
and x_max
), you can scale this down to a number between 1 and 10 (a
and b
). This might be useful to generate indexes for a lookup table or scale enemy speed by the player's score, or whatever else you can think of.
-- scale x between a and b, given x's min and max range
-- returns a number between a and b
function linearScaleBetween(_x,_a,_b,_xMin,_xMax)
return (((_b-_a)*(_x-_xMin))/(_xMax-_xMin))+_a
end
Another simple method I get a lot of mileage out of is just a random table item generator:
function getRandomTableItem(_table)
return _table[ math.random( #_table ) ]
end
I find myself using both of these in every project I make. Enjoy
Hey @professir! Good one. In that vein, here are some functions I use that are complementary to the one you shared:
-- Get a version of val that does not spill outside of min and max.
-- Example: clamp(1.5, -1.1, 1.1) returns 1.1
function clamp(val, min, max)
return val < min and min or val > max and max or val
end
-- Find number between two numbers (a, b) at t [0, 1]
-- Example: lerp(10, 30, 0.5) returns 20 (halfway between 10 and 30).
function lerp(a, b, t)
return math.ceil(a * (1 - t) + b * t)
end
-- Find value [0, 1] between two numbers (a, b) at v
-- Example: invlerp(10, 30, 20) returns 0.5
function invlerp(a, b, v)
return clamp((v - a) / (b - a), 0, 1)
end
-- Finds value [0, 1] of input between output.
-- Example: remap(20, 10, 30, 30, 50) returns 40 as 20 is halfway between 10 and 30 and remapped to new range 30 to 50 is 40. This function allows you to remap a value between two ranges.
function remap(input_val, input_min, input_max, output_min, output_max)
return lerp(output_min, output_max, invlerp(input_min, input_max, input_val))
end
Here's something I put together after finding myself repeatedly reaching for animated patterns. I'll acknowledge up front that this isn't the best approach when it comes to performance (an imageable
would be faster; also, I haven't done any optimization work here yet). That said, it's quick and fun to prototype with. (I'll also plug the Roto utility I made which could be used to export the animated output from this to a sprite sheet as needed.)
Usage
Initialize with a table containing the pattern and animation properties (see the full implementation below for a complete list), and then all it takes is one line to apply it when drawing…
-- initialize
local checkerboard = {0xF0F0, 0xF0F0, 0xF0F0, 0xF0F0, 0x0F0F, 0x0F0F, 0x0F0F, 0x0F0F}
self.easyp = EasyPattern {
pattern = checkerboard,
phaseDuration = 1.0,
phaseFunction = playdate.easingFunctions.inOutCubic,
-- <list any additional animation params here>
}
-- in draw function
playdate.graphics.setPattern(self.easyp:apply())
Examples
Here's an example of a simple horizontal conveyor belt effect:
self.easyp = EasyPattern {
ditherType = gfx.image.kDitherTypeVerticalLine,
xPhaseDuration = 0.5
}
One for a downward stair bounce effect:
self.easyp = EasyPattern {
pattern = checkerboard,
yPhaseDuration = 1.0,
yPhaseFunction = playdate.easingFunctions.outBounce,
yReversed = true,
scale = 2
}
One that pans in a circular motion:
self.easyp = EasyPattern {
pattern = checkerboard,
phaseDuration = 0.5,
phaseFunction = playdate.easingFunctions.inOutSine,
xPhaseOffset = 0.25,
reverses = true,
scale = 2
}
And finally one that demonstrates a custom easing function. Here I'm generating a "random" Perlin noise movement, which could be used for e.g. simulating leaves rustling.
self.easyp = EasyPattern {
pattern = checkerboard,
xPhaseDuration = 3.5,
yPhaseDuration = 2.5,
xPhaseFunction = function(t, b, c, d) return playdate.graphics.perlin(t / d, 1, 2, 3, 3, 0.5) end,
yPhaseFunction = function(t, b, c, d) return playdate.graphics.perlin(t / d, 4, 5, 6, 3, 0.5) end,
scale = 50
}
Implementation
The code isn't doing anything especially complicated, but I did get a bit carried away with the parameterization so file length grew quickly. To make it easier to view the code and docs, I threw it up on GitHub:
Any thoughts on optimization are welcome!
Here's a minimal Settings class that handles defaults, fetch, and store on top of Datastore.
class("Settings").extends()
function Settings:init(default_settings)
self.defaults = default_settings or {}
self.settings = nil
self:load()
end
function Settings:set(k, v)
self.settings[k] = v
end
function Settings:get(k)
return self.settings[k]
end
function Settings:save()
playdate.datastore.write(self.settings, "settings", true)
end
function Settings:load()
self.settings = table.deepcopy(self.defaults)
local current_settings = playdate.datastore.read("settings") or {}
for k, v in pairs(current_settings) do
self.settings[k] = v
end
end
An example of how I use it:
-- main.lua
-- I like to keep my settings keys global and descriptive.
SettingKeys = {
helpEnabled = "help enabled",
deaths = "death count"
}
-- Create a global settings object with default values for keys.
settings = Settings({
[SettingKeys.helpEnabled] = true,
[SettingKeys.deaths] = 0
})
-- example.lua
playdate.getSystemMenu():addCheckmarkMenuItem("help", settings:get(SettingKeys.helpEnabled), function(enabled)
settings:set(SettingKeys.helpEnabled, enabled)
settings:save()
end)
Apologies for the cross-post, but after sharing about a visual debugging tool that I put together I was reminded of this thread and realized it would be a handy place for others to find it, so I'll just drop the link:
Thanks for that trick! It's a bit less of a general utility function this way, but you can take the wrapper up one level and use table calling syntax to make it a touch cleaner:
-- craft a well-formed pattern from a list of binary strings
function BitPattern (binaryRows)
pattern = {}
for i, binary in ipairs(binaryRows) do
pattern[i] = tonumber(binary, 2)
end
return pattern
end
local BP = BitPattern
gfx.setPattern( BP {
'11110000',
'11100001',
'11000011',
'10000111',
'00001111',
'00011110',
'00111100',
'01111000',
})
Okay, one more extension of that same idea…
I don't use alpha channels for patterns often, but I didn't like that they took up so much vertical real estate in the file. This version automatically swizzles the inputs to enable placing the pattern and alpha channel definitions side by side, like so:
gfx.setPattern( BitPattern {
-- PTTRN ALPHA
'10101010', '00010000',
'01010101', '00111000',
'10101010', '01111100',
'01010101', '11111110',
'10101010', '01111100',
'01010101', '00111000',
'10101010', '00010000',
'01010101', '00000000',
})
This keeps things more compact, and I also find it easier to make sense of since the pattern and alpha channel rows can be compared side-by-side. Here's a function which supports patterns with or without alpha. It's highly specialized to 8x8 patterns, of course, but pretty convenient:
function BitPattern(binaryRows)
local hasAlpha = #binaryRows == 16
local pattern = {}
for i, binaryRow in ipairs(binaryRows) do
if hasAlpha then
-- swizzle the rows to produce independent channels from interleaved inputs
pattern[i//2 + (i % 2 == 0 and 8 or 1)] = tonumber(binaryRow, 2)
else
-- no alpha channel, nothing to swizzle
pattern[i] = tonumber(binaryRow, 2)
end
end
return pattern
end