Thank you all for pointing me in the right direction. I believe I have something working that meets all my above criteria (dithered edges that blend into the darkness, support for multiple sources of light that bleed into each other when overlapping, ability to resize on the fly for light flickering effects, etc):
It does require updating the entire screen each frame - it seems like there should be a way to do this that only updates the portion of the screen that are lit up. But I am not sure how to do that at the moment. I think it updates the whole thing because I'm simply creating a new 400, 240 image each frame to use as an image mask. Any ideas on how to make this more efficient, or is this as good as it gets?
It's not the prettiest, but I'll post the source code here for this example in case others have a similar problem in the future (and if I have time later maybe I will add comments or clean it up some as well):
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "circle"
local pd <const> = playdate
local gfx <const> = pd.graphics
local lightObjects = {}
local function initialize()
playdate.display.setRefreshRate(30)
local bgImage = gfx.image.new("images/bg")
local bgSprite = gfx.sprite.new(bgImage)
bgSprite:add()
bgSprite:moveTo(200, 120)
local staticImage = gfx.image.new(12, 12)
gfx.pushContext(staticImage)
gfx.fillCircleAtPoint(6, 6, 6)
gfx.popContext()
local staticSprite = gfx.sprite.new(staticImage)
--staticSprite:add()
staticSprite:moveTo(88, 8)
table.insert(lightObjects, staticSprite)
local circleSprite = Circle(200, 120, 1)
circleSprite:add()
circleSprite:moveTo(20, 120)
table.insert(lightObjects, circleSprite)
local darknessImage = gfx.image.new(400, 240, gfx.kColorBlack)
darknessImage:addMask()
darknessSprite = gfx.sprite.new(darknessImage)
darknessSprite:add()
darknessSprite:moveTo(200, 120)
end
local function handleLighting()
local darknessImage = gfx.image.new(400, 240, gfx.kColorBlack)
local maskImage = gfx.image.new(400, 240, gfx.kColorWhite)
gfx.pushContext(maskImage)
local width, height = maskImage:getSize()
for i,lightObj in ipairs(lightObjects) do
local xx, yy = lightObj:getPosition()
local circle_size, max_rings, core_size = 80 - math.random(-2, 2), 8, 48
local edge_size = circle_size - core_size
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(0, gfx.image.kDitherTypeBayer8x8)
gfx.fillCircleAtPoint(xx, yy, core_size)
for j = 1,max_rings do
local ring_strength = (j/max_rings)
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(ring_strength, gfx.image.kDitherTypeBayer8x8)
gfx.fillCircleAtPoint(xx, yy, (edge_size * ring_strength) + core_size)
end
end
gfx.popContext()
darknessImage:setMaskImage(maskImage)
darknessSprite:setImage(darknessImage)
end
initialize()
function pd.update()
handleLighting()
gfx.sprite.update()
end