import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
import "CoreLibs/timer"
import "CoreLibs/crank"
local pd = playdate
local gfx = pd.graphics
local shieldPulseTime = 0
local SCREEN_W, SCREEN_H = 400, 240
local meteor = {
active = false,
x = 0,
y = 0,
vx = 0,
vy = 0,
timer = 0,
cooldown = 180,
trail = {}
}
local NUM_STARS = 30
local stars = {}
for i = 1, NUM_STARS do
stars[i] = {
x = math.random(0, SCREEN_W-1),
y = math.random(0, 200),
phase = math.random() * math.pi*2,
speed = 0.5 + math.random() * 0.8
}
end
local starSprite = gfx.sprite.new()
starSprite:setSize(SCREEN_W, SCREEN_H)
starSprite:setCenter(0, 0)
starSprite:moveTo(0, 0)
starSprite:setZIndex(-5)
function starSprite:draw()
for _, s in ipairs(stars) do
local a = (math.sin(s.phase) + 1) / 2
if a > 0.2 then
gfx.setColor(gfx.kColorWhite)
if a > 0.8 then
gfx.fillRect(s.x, s.y, 2, 2)
else
gfx.drawPixel(s.x, s.y)
end
end
end
function starSprite:draw()
for _, s in ipairs(stars) do
local a = (math.sin(s.phase) + 1) / 2
if a > 0.2 then
gfx.setColor(gfx.kColorWhite)
if a > 0.8 then
gfx.fillRect(s.x, s.y, 2, 2)
else
gfx.drawPixel(s.x, s.y)
end
end
end
if meteor.active or #meteor.trail > 0 then
gfx.setColor(gfx.kColorWhite)
local trailCount = #meteor.trail
for i, p in ipairs(meteor.trail) do
local t = (trailCount - i + 1) / trailCount
local len = 6 + 10 * t
local offX = -len * 0.8
local offY = len * 0.4
if t > 0.66 then
gfx.drawLine(p.x, p.y, p.x + offX, p.y + offY)
gfx.drawLine(p.x, p.y + 1, p.x + offX, p.y + offY + 1)
gfx.drawLine(p.x, p.y - 1, p.x + offX, p.y + offY - 1)
elseif t > 0.33 then
gfx.drawLine(p.x, p.y, p.x + offX, p.y + offY)
gfx.drawLine(p.x, p.y + 1, p.x + offX, p.y + offY + 1)
else
gfx.drawLine(p.x, p.y, p.x + offX * 0.6, p.y + offY * 0.6)
end
end
if meteor.active then
gfx.fillRect(meteor.x - 2, meteor.y - 2, 5, 5)
end
end
end
end
function starSprite:update()
for _, s in ipairs(stars) do
s.phase = s.phase + s.speed
if s.phase > math.pi * 2 then
s.phase = s.phase - math.pi * 2
end
end
if meteor.active then
meteor.x = meteor.x + meteor.vx
meteor.y = meteor.y + meteor.vy
meteor.timer = meteor.timer - 1
table.insert(meteor.trail, 1, { x = meteor.x, y = meteor.y })
if #meteor.trail > 7 then
table.remove(meteor.trail)
end
if meteor.timer <= 0
or meteor.x < -30
or meteor.y > SCREEN_H + 30 then
meteor.active = false
meteor.cooldown = math.random(260, 520)
meteor.trail = {}
end
else
if meteor.cooldown > 0 then
meteor.cooldown = meteor.cooldown - 1
else
if math.random() < 0.04 then
meteor.active = true
meteor.x = math.random(320, 420)
meteor.y = math.random(0, 60)
meteor.vx = -4 - math.random() * 2
meteor.vy = 2 + math.random() * 1.5
meteor.timer = math.random(45, 70)
meteor.cooldown = math.random(320, 640)
meteor.trail = {}
end
end
end
end
starSprite:add()
gfx.sprite.update()
local owlImage = gfx.image.new("images/owl")
local bgImage = gfx.image.new("images/forest1")
local obstacleImage = gfx.image.new("images/rocklittle")
local obstacle = gfx.sprite.new(obstacleImage)
local ow, oh = obstacleImage:getSize()
obstacle:setCollideRect(0, 0, ow, oh)
obstacle:moveTo(420, math.random(50, 190))
obstacle:add()
local titleImage = gfx.image.new("images/title")
local gameOverImage = gfx.image.new("images/gameover1")
local function drawHeart(x, y)
gfx.setColor(gfx.kColorBlack)
gfx.fillCircleAtPoint(x + 4, y + 4, 4)
gfx.fillCircleAtPoint(x + 12, y + 4, 4)
gfx.fillTriangle(
x, y + 6,
x + 16, y + 6,
x + 8, y + 16
)
end
local owl = gfx.sprite.new(owlImage)
owl:moveTo(60, SCREEN_H / 2)
local owlW, owlH = owlImage:getSize()
owl:setCollideRect(0, 0, owlW, owlH)
owl:add()
local bg1 = gfx.sprite.new(bgImage)
bg1:setZIndex(-10)
bg1:moveTo(SCREEN_W / 2, SCREEN_H / 2)
bg1:add()
local bg2 = gfx.sprite.new(bgImage)
bg2:setZIndex(-10)
bg2:moveTo(SCREEN_W * 1.5, SCREEN_H / 2)
bg2:add()
local obstacle = gfx.sprite.new(obstacleImage)
local ow, oh = obstacleImage:getSize()
obstacle:setCollideRect(0, 0, ow, oh)
obstacle:moveTo(420, math.random(50, 190))
obstacle:add()
local gameState = "title"
local score = 0
local bestScore = 0
local obstacleSpeed = 3
local lives = 3
local invincible = false
local invincibleTimer = 0
local dashActive = false
local dashTimer = 0
local DASH_DURATION_FRAMES = 18
local fadeAlpha = 0
local fading = false
local blinkTimer = 0
do
local data = pd.datastore.read()
if data and data.bestScore then
bestScore = data.bestScore
end
end
local function resetRun()
score = 0
obstacleSpeed = 3
lives = 3
invincible = false
invincibleTimer = 0
owl:moveTo(60, SCREEN_H / 2)
obstacle:moveTo(420, math.random(50, 190))
bg1:moveTo(SCREEN_W / 2, SCREEN_H / 2)
bg2:moveTo(SCREEN_W * 1.5, SCREEN_H / 2)
fadeAlpha = 1
fading = true
gameState = "active"
end
local function handleCollision()
if invincible then
return
end
if #owl:overlappingSprites() > 0 then
lives = lives - 1
invincible = true
invincibleTimer = 60
owl:moveBy(-10, -10)
obstacle:moveTo(420, math.random(50, 190))
if lives <= 0 then
if score > bestScore then
bestScore = score
playdate.datastore.write({ bestScore = bestScore })
end
gameState = "gameover"
end
end
end
local function updateFade()
if fading then
fadeAlpha = fadeAlpha - 0.05
if fadeAlpha <= 0 then
fadeAlpha = 0
fading = false
end
elseif gameState == "gameover" and fadeAlpha < 1 then
fadeAlpha = fadeAlpha + 0.05
if fadeAlpha > 1 then
fadeAlpha = 1
end
end
end
local function drawFade()
if fadeAlpha <= 0 then return end
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(fadeAlpha, gfx.image.kDitherTypeBayer8x8)
gfx.fillRect(0, 0, SCREEN_W, SCREEN_H)
end
local function updateBackground(dt)
local speed = (gameState == "active") and 3 or 0
if speed == 0 then
return
end
local dx = -speed
bg1:moveBy(dx, 0)
bg2:moveBy(dx, 0)
if bg1.x < -SCREEN_W / 2 then
bg1:moveTo(bg2.x + SCREEN_W, SCREEN_H / 2)
end
if bg2.x < -SCREEN_W / 2 then
bg2:moveTo(bg1.x + SCREEN_W, SCREEN_H / 2)
end
end
local function updateOwl(dt)
local crankPos = pd.getCrankPosition()
local dy = 0
if crankPos > 20 and crankPos < 160 then
dy = -4
elseif crankPos > 200 or crankPos < 20 then
dy = 4
end
local newY = owl.y + dy
if newY < 20 then newY = 20 end
if newY > SCREEN_H - 20 then newY = SCREEN_H - 20 end
owl:moveTo(owl.x, newY)
end
local function updateObstacle(dt)
obstacle:moveBy(-obstacleSpeed, 0)
if obstacle.x < -20 then
score = score + 1
obstacleSpeed = obstacleSpeed + 0.2
obstacle:moveTo(420, math.random(50, 190))
end
end
local function drawHUD()
gfx.setColor(gfx.kColorWhite)
gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer8x8)
gfx.fillRoundRect(0, 0, SCREEN_W, 32, 4)
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)
local hudText = string.format("Score: %d Best: %d", score, bestScore)
gfx.setColor(gfx.kColorBlack)
gfx.drawText(hudText, 249, 10)
gfx.drawText(hudText, 251, 10)
gfx.drawText(hudText, 250, 9)
gfx.drawText(hudText, 250, 11)
gfx.setColor(gfx.kColorBlack)
gfx.drawText(hudText, 250, 10)
gfx.drawText(hudText, 250, 10)
for i = 1, lives do
drawHeart(10 + (i - 1) * 20, 6)
end
end
function playdate.update()
local dt = pd.getElapsedTime()
pd.resetElapsedTime()
shieldPulseTime = shieldPulseTime + dt
gfx.clear(gfx.kColorWhite)
gfx.sprite.update()
if gameState == "title" then
if titleImage then
titleImage:draw(0, 0)
end
blinkTimer = blinkTimer + 1
if blinkTimer >= 60 then blinkTimer = 0 end
if blinkTimer < 30 then
gfx.drawTextAligned("Press A to Start", SCREEN_W / 2, 190, kTextAlignment.center)
end
elseif gameState == "active" then
updateBackground(dt)
updateOwl(dt)
updateObstacle(dt)
if invincible then
invincibleTimer = invincibleTimer -1
if invincibleTimer <= 0 then
invincible = false
end
end
if dashActive then
dashTimer = dashTimer -1
if dashTimer <= 0 then
dashActive = false
if invincibleTimer <= 0 then
invincible = false
end
end
end
handleCollision()
drawHUD()
if dashActive then
local x, y = owl.x, owl.y
local pulse = math.sin(shieldPulseTime * 10) * 4
local baseOuter = 30
local baseMid = 24
local baseInner = 18
local outerR = baseOuter + pulse
local midR = baseMid + pulse * 0.7
local innerR = baseInner + pulse * 0.4
gfx.setColor(gfx.kColorWhite)
gfx.setDitherPattern(0.25, gfx.image.kDitherTypeBayer8x8)
gfx.fillCircleAtPoint(x, y, outerR)
gfx.setDitherPattern(0.45, gfx.image.kDitherTypeBayer8x8)
gfx.fillCircleAtPoint(x, y, midR)
gfx.setDitherPattern(0.7, gfx.image.kDitherTypeBayer8x8)
gfx.fillCircleAtPoint(x, y, innerR)
gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)
gfx.drawCircleAtPoint(x, y, outerR)
gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)
end
elseif gameState == "paused" then
drawHUD()
local boxW, boxH = 200, 50
local boxX = (SCREEN_W - boxW) / 2
local boxY = (SCREEN_H - boxH) / 2
gfx.setColor(gfx.kColorWhite)
gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer8x8)
gfx.fillRoundRect(boxX, boxY, boxW, boxH, 8)
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(1.0, gfx.image.kDitherTypeBayer8x8)
local function drawBoldTextAligned(text, x, y)
gfx.setColor(gfx.kColorBlack)
gfx.drawTextAligned(text, x-1, y, kTextAlignment.center)
gfx.drawTextAligned(text, x+1, y, kTextAlignment.center)
gfx.drawTextAligned(text, x, y-1, kTextAlignment.center)
gfx.drawTextAligned(text, x, y+1, kTextAlignment.center)
gfx.drawTextAligned(text, x, y, kTextAlignment.center)
end
drawBoldTextAligned("PAUSED", SCREEN_W / 2, boxY + 6)
gfx.drawTextAligned("Press B to Resume", SCREEN_W / 2, boxY + 28, kTextAlignment.center)
elseif gameState == "gameover" then
if gameOverImage then
gameOverImage:draw(0, 0)
else
gfx.drawTextAligned("GAME OVER", SCREEN_W / 2, 80, kTextAlignment.center)
end
gfx.drawTextAligned("Score: " .. score, SCREEN_W / 2, 130, kTextAlignment.center)
gfx.drawTextAligned("Best: " .. bestScore, SCREEN_W / 2, 150, kTextAlignment.center)
gfx.drawTextAligned("Press A to Restart", SCREEN_W / 2, 190, kTextAlignment.center)
end
updateFade()
drawFade()
end
function playdate.AButtonDown()
if gameState == "title" then
resetRun()
elseif gameState == "gameover" then
resetRun()
elseif gameState == "active" then
if not dashActive then
dashActive = true
dashTimer = DASH_DURATION_FRAMES
invincible = true
if invincibleTimer < DASH_DURATION_FRAMES then
invincibleTimer = DASH_DURATION_FRAMES
end
end
end
end
function playdate.BButtonDown()
if gameState == "active" then
gameState = "paused"
elseif gameState == "paused" then
gameState = "active"
end
end