I was looking at hologram fans, and I realized how conceptually simple it is to make a stick of lights display an image. The fan is just a rapidly-spinning, frequently-updated image mask! Playdate can do that.
Playdate's image and drawing functions are some of my favorite parts of the SDK, even with just a 1-bit display. Constraints foster creativity, after all. Maybe this will help someone learn how to use image masks?
At first, I drew straight lines on the mask image, but partial ellipses worked out better. The spinning fan mask can create gray and wagon-wheel effects. Here's example output, code, and my incredibly artistic stick figure animation.
import 'CoreLibs/animation'
-- Angle range of partial ellipses
local ELLIPSE_RANGE <const> = 30
-- How much to change the fan's per-frame angle change when pressing up or down
local FAN_DELTA_INPUT_STEP <const> = 1 / 10
-- Animation constants
local ANIMATION_PATH <const> = 'animation'
local ANIMATION_SPEED <const> = 100
-- Where to draw the animation
local WHERE_X <const> = 200
local WHERE_Y <const> = 120
-- Less typing and reading
local graphics <const> = playdate.graphics
-- Variables to control fan spin
local fanDelta = 20
local fanDeltaInput = 0
-- Variables to control fan blade configuration
local ellipseDelta = 90
local ellipseParts = math.floor(360 / ellipseDelta)
-- Animation
local animation <const> = graphics.animation.loop.new(
ANIMATION_SPEED,
graphics.imagetable.new(ANIMATION_PATH),
true
)
local width <const>, height <const> = animation:image():getSize()
-- Fan mask
local mask <const> = graphics.image.new(width, height)
local ellipseSize <const> = math.sqrt((width ^ 2) + (height ^ 2))
local ellipseX <const> = (width - ellipseSize) / 2
local ellipseY <const> = (height - ellipseSize) / 2
-- Variable to track fan spin
local angle = 0
-- Go fast
playdate.display.setRefreshRate(50)
-- White for drawing to mask
graphics.setColor(graphics.kColorWhite)
function playdate.update()
-- Get current animation image
local image <const> = animation:image()
-- Variable for drawing fan blades
local ellipseAngle = angle
-- Update fan rotation speed
fanDelta += fanDeltaInput * FAN_DELTA_INPUT_STEP
-- Create frame's fan mask
mask:clear(graphics.kColorBlack)
graphics.pushContext(mask)
for i = 1, ellipseParts do
graphics.fillEllipseInRect(
ellipseX,
ellipseY,
ellipseSize,
ellipseSize,
ellipseAngle,
ellipseAngle + ELLIPSE_RANGE
)
ellipseAngle = (ellipseAngle + ellipseDelta) % 360
end
graphics.popContext()
-- Apply fan mask to animation
image:setMaskImage(mask)
-- Clear animation area
graphics.fillRect(WHERE_X, WHERE_Y, width, height)
-- Draw animation
image:draw(WHERE_X, WHERE_Y)
-- Clear variable display area
graphics.fillRect(0, 0, 400, 80)
-- Display interesting variable values
graphics.drawText(
string.format(
'Fan delta theta: %f'..
'\nEllipse delta theta: %f'..
'\nEllipses: %d', fanDelta, ellipseDelta, ellipseParts
),
0,
0
)
-- Display actual frame rate
playdate.drawFPS(0, 80)
-- Spin fan
angle = (angle + fanDelta) % 360
end
function playdate.cranked(change, acceleratedChange)
-- Update angular distance between fan blades
ellipseDelta = (ellipseDelta + change) % 360
-- Update number of fan blades
ellipseParts = math.floor(360 / ellipseDelta)
end
function playdate.downButtonDown()
-- Decrease fan rotation speed
fanDeltaInput = -1
end
function playdate.downButtonUp()
-- Maintain fan rotation speed
fanDeltaInput = 0
end
function playdate.upButtonDown()
-- Increase fan rotation speed
fanDeltaInput = 1
end
function playdate.upButtonUp()
-- Maintain fan rotation speed
fanDeltaInput = 0
end
function playdate.leftButtonDown()
-- Decrease refresh rate
playdate.display.setRefreshRate(playdate.display.getRefreshRate() - 1)
end
function playdate.rightButtonDown()
-- Increase refresh rate
playdate.display.setRefreshRate(playdate.display.getRefreshRate() + 1)
end