Add simple main menu (disabled for now)

String names on Logo assets
Add dbg.drawLine() - to use when defining outfield boundaries.
Allow flipped fielder draws.
This commit is contained in:
Sage Vaillancourt 2025-02-14 15:42:10 -05:00
parent e710a79d9c
commit 8943eef73f
12 changed files with 264 additions and 66 deletions

1
selene.toml Normal file
View File

@ -0,0 +1 @@
std = "lua53"

View File

@ -1,36 +1,28 @@
---@alias ActionResult {}
---@type table<string, ActionResult>
-- selene: allow(unscoped_variables)
ActionResult = {
Succeeded = {},
Failed = {},
NeedsMoreTime = {},
}
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)
actionQueue = { actionQueue = {
---@type ({ action: Action, expireTimeMs: number })[] ---@type table<any, { coroutine: thread, expireTimeMs: number }>
queue = {}, queue = {},
} }
---@alias Action fun(deltaSeconds: number): ActionResult ---@alias Action fun(deltaSeconds: number)
--selene: allow(incorrect_standard_library_use)
local close = coroutine.close
--- Added actions will be called on every runWaiting() update. --- Added actions will be called on every runWaiting() update.
--- They will continue to be executed until they return Succeeded or Failed instead of NeedsMoreTime. --- They will continue to be executed until they return Succeeded or Failed instead of NeedsMoreTime.
--- ---
--- Replaces any existing action with the given name. --- Replaces any existing action with the given id.
--- If the initial call of action() doesn't return NeedsMoreTime, this function will not bother adding it to the queue. --- If the initial call of action() doesn't return NeedsMoreTime, this function will not bother adding it to the queue.
---@param name string ---@param id any
---@param maxTimeMs number ---@param maxTimeMs number
---@param action Action ---@param action Action
function actionQueue:upsert(name, maxTimeMs, action) function actionQueue:upsert(id, maxTimeMs, action)
if action(0) ~= ActionResult.NeedsMoreTime then if self.queue[id] then
return close(self.queue[id].coroutine)
end end
self.queue[id] = {
self.queue[name] = { coroutine = coroutine.create(action),
action = action,
expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(), expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(),
} }
end end
@ -39,10 +31,16 @@ end
--- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired. --- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired.
function actionQueue:runWaiting(deltaSeconds) function actionQueue:runWaiting(deltaSeconds)
local currentTimeMs = playdate.getCurrentTimeMilliseconds() local currentTimeMs = playdate.getCurrentTimeMilliseconds()
for name, actionObject in pairs(self.queue) do
local result = actionObject.action(deltaSeconds) for id, actionObject in pairs(self.queue) do
if result ~= ActionResult.NeedsMoreTime or currentTimeMs > actionObject.expireTimeMs then coroutine.resume(actionObject.coroutine, deltaSeconds)
self.queue[name] = nil
if currentTimeMs > actionObject.expireTimeMs then
close(actionObject.coroutine)
end
if coroutine.status(actionObject.coroutine) == "dead" then
self.queue[id] = nil
end end
end end
end end

View File

@ -15,6 +15,9 @@ PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png")
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png") GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
GameLogo = playdate.graphics.image.new("images/game/GameLogo.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Hat = playdate.graphics.image.new("images/game/Hat.png") Hat = playdate.graphics.image.new("images/game/Hat.png")
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
@ -57,30 +60,31 @@ TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav")
Logos = { Logos = {
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Cats = playdate.graphics.image.new("images/game/logos/Cats.png"), { name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Hearts = playdate.graphics.image.new("images/game/logos/Hearts.png"), { name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Checkmarks = playdate.graphics.image.new("images/game/logos/Checkmarks.png"), { name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Smiles = playdate.graphics.image.new("images/game/logos/Smiles.png"), { name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
FingerGuns = playdate.graphics.image.new("images/game/logos/FingerGuns.png"), { name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Base = playdate.graphics.image.new("images/game/logos/Base.png"), { name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Frown = playdate.graphics.image.new("images/game/logos/Frown.png"), { name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Arrows = playdate.graphics.image.new("images/game/logos/Arrows.png"), { name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") },
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Turds = playdate.graphics.image.new("images/game/logos/Turds.png"), { name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.png") },
} }

View File

@ -1,5 +1,8 @@
!(function dirLookup(dir, extension, newFunc, sep) !(function dirLookup(dir, extension, newFunc, sep, handle)
sep = sep or "\n" sep = sep or "\n"
handle = handle ~= nil and handle or function(varName, value)
return varName .. ' = ' .. value
end
--Open directory look for files, save data in p. By giving '-type f' as parameter, it returns all files. --Open directory look for files, save data in p. By giving '-type f' as parameter, it returns all files.
local p = io.popen('find src/' .. dir .. ' -type f -maxdepth 1') local p = io.popen('find src/' .. dir .. ' -type f -maxdepth 1')
@ -11,7 +14,7 @@
file = file:gsub("src/", "") file = file:gsub("src/", "")
assetCode = assetCode .. '--selene: allow(unused_variable)\n' assetCode = assetCode .. '--selene: allow(unused_variable)\n'
assetCode = assetCode .. '--selene: allow(unscoped_variables)\n' assetCode = assetCode .. '--selene: allow(unscoped_variables)\n'
assetCode = assetCode .. varName .. ' = ' .. newFunc .. '("' .. file .. '")' .. sep assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
end end
end end
return assetCode return assetCode
@ -27,5 +30,11 @@ end)!!(generatedFileWarning())
--selene: allow(unused_variable) --selene: allow(unused_variable)
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
Logos = { Logos = {
!!(dirLookup('images/game/logos', 'png', 'playdate.graphics.image.new', ",\n")) --selene: allow(unused_variable)
--selene: allow(unscoped_variables)
{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
!!(dirLookup('images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', ",\n", function(varName, value)
return '{ name = "' .. varName .. '", image = ' .. value .. ' }'
end))
} }

View File

@ -38,6 +38,16 @@ function dbg.loadTheBases(br)
br.runners[4].nextBase = C.Bases[C.Home] br.runners[4].nextBase = C.Bases[C.Home]
end end
-- selene: allow(unused_variable)
---@param points XyPair[]
function dbg.drawLine(points)
for i = 2, #points do
local prev = points[i - 1]
local next = points[i]
playdate.graphics.drawLine(prev.x, prev.y, next.x, next.y)
end
end
if not playdate then if not playdate then
return dbg return dbg
end end

View File

@ -5,14 +5,14 @@ local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
---@param fielderX number ---@param fielderX number
---@param fielderY number ---@param fielderY number
---@return boolean isHoldingBall ---@return boolean isHoldingBall
local function drawFielderGlove(ball, fielderX, fielderY) local function drawFielderGlove(ball, fielderX, fielderY, flip)
local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z) local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
local shoulderX, shoulderY = fielderX + 10, fielderY - 5 local shoulderX, shoulderY = fielderX + 10, fielderY - 5
if distanceFromBall > 20 then if distanceFromBall > 20 then
Glove:draw(shoulderX, shoulderY) Glove:draw(shoulderX, shoulderY, flip)
return false return false
else else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY) GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
return true return true
end end
end end
@ -22,7 +22,7 @@ end
---@param x number ---@param x number
---@param y number ---@param y number
---@return boolean isHoldingBall ---@return boolean isHoldingBall
function drawFielder(playerSprites, ball, x, y) function drawFielder(playerSprites, ball, x, y, flip)
playerSprites.smiling:draw(x, y - 20) playerSprites.smiling:draw(x, y - 20, flip)
return drawFielderGlove(ball, x, y) return drawFielderGlove(ball, x, y)
end end

View File

@ -15,6 +15,7 @@ function maybeDrawInverted(image, x, y, drawInverted)
gfx.setImageDrawMode(drawMode) gfx.setImageDrawMode(drawMode)
end end
--- TODO: Custom names on jerseys?
---@return SpriteCollection ---@return SpriteCollection
---@param base pd_image ---@param base pd_image
---@param isDark boolean ---@param isDark boolean
@ -52,7 +53,20 @@ function buildCollection(base, back, logo, isDark)
end end
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, Logos.Base, true) ---@type SpriteCollection
AwayTeamSprites = nil
--selene: allow(unscoped_variables) --selene: allow(unscoped_variables)
HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, Logos.Frown, false) ---@type SpriteCollection
HomeTeamSprites = nil
function replaceAwayLogo(logo)
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, logo, true)
end
function replaceHomeLogo(logo)
HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, logo, false)
end
replaceAwayLogo(Logos[1].image)
replaceHomeLogo(Logos[2].image)

View File

@ -153,11 +153,11 @@ end
---@param targetBase Base ---@param targetBase Base
---@param launchBall LaunchBall ---@param launchBall LaunchBall
---@param throwFlyMs number ---@param throwFlyMs number
---@return ActionResult local function userThrowToCoroutine(field, targetBase, launchBall, throwFlyMs)
local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs) while true do
if field.fielderHoldingBall == nil then if field.fielderHoldingBall == nil then
return ActionResult.NeedsMoreTime coroutine.yield()
end else
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder) local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end) end)
@ -165,7 +165,10 @@ local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs)
closestFielder.target = targetBase closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall) Fielding.markIneligible(field.fielderHoldingBall)
return ActionResult.Succeeded
return
end
end
end end
--- Buffer in a fielder throw action. --- Buffer in a fielder throw action.
@ -176,7 +179,7 @@ end
function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs) function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
local maxTryTimeMs = 5000 local maxTryTimeMs = 5000
actionQueue:upsert("userThrowTo", maxTryTimeMs, function() actionQueue:upsert("userThrowTo", maxTryTimeMs, function()
return userThrowToImpl(self, targetBase, launchBall, throwFlyMs) userThrowToCoroutine(self, targetBase, launchBall, throwFlyMs)
end) end)
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

123
src/main-menu.lua Normal file
View File

@ -0,0 +1,123 @@
-- selene: allow(unscoped_variables)
---@class MainMenu
MainMenu = {
mainGameUpdateFunction = nil,
mainGameInitFunction = nil,
}
-- selene: allow(shadowing)
local gfx = playdate.graphics
-- selene: allow(shadowing)
local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft")
--- Take control of playdate.update
---@param config MainMenu Function that controls the main gameplay loop.
--- Will replace playdate.update when the menu is done.
function MainMenu.start(config)
MainMenu.mainGameUpdateFunction = config.mainGameUpdateFunction
MainMenu.mainGameInitFunction = config.mainGameInitFunction
playdate.update = MainMenu.update
end
local function startGame()
MainMenu.mainGameInitFunction()
playdate.update = MainMenu.mainGameUpdateFunction
end
local function pausingEaser(baseEaser)
--- t: elapsedTime
--- d: duration
return function(t, b, c, d)
local percDone = t / d
if percDone > 0.9 then
t = d
elseif percDone < 0.1 then
t = 0
else
t = (percDone - 0.1) * 1.25 * d
end
return baseEaser(t, b, c, d)
end
end
local animatorX = gfx.animator.new(2000, 30, 350, pausingEaser(playdate.easingFunctions.linear))
animatorX.repeatCount = -1
animatorX.reverses = true
local animatorY = gfx.animator.new(2000, 60, 200, pausingEaser(utils.easingHill))
animatorY.repeatCount = -1
animatorY.reverses = true
local crankStartPos = nil
---@generic T
---@param array T[]
---@param crankPosition number
---@return T
local function arrayElementFromCrank(array, crankPosition)
local i = math.ceil(#array * (crankPosition + 0.001) / 360)
return array[i]
end
local currentLogo = nil
local inningCountSelection = 3
function playdate.upButtonDown()
inningCountSelection = inningCountSelection + 1
end
function playdate.downButtonDown()
inningCountSelection = math.max(1, inningCountSelection - 1)
end
local itr = 0
local t = playdate.timer.new(1000)
t:reset()
function t.updateCallback()
itr = itr + 1
end
function MainMenu.update()
playdate.timer.updateTimers()
crankStartPos = crankStartPos or playdate.getCrankPosition()
if playdate.getCrankChange() ~= 0 then
local crankOffset = (crankStartPos - playdate.getCrankPosition()) % 360
currentLogo = arrayElementFromCrank(Logos, crankOffset).image
replaceAwayLogo(currentLogo)
end
gfx.clear()
if playdate.buttonIsPressed(playdate.kButtonA) then
startGame()
end
gfx.drawText(tostring(itr), 200, 120)
GameLogo:drawCentered(C.Center.x, 50)
StartFont:drawTextAligned("Press A to start!", C.Center.x, 140, kTextAlignment.center)
gfx.drawTextAligned("with " .. inningCountSelection .. " innings", C.Center.x, 190, kTextAlignment.center)
local ball = {
x = animatorX:currentValue(),
y = animatorY:currentValue(),
z = 6,
size = 6,
}
local ballIsHeld = drawFielder(AwayTeamSprites, ball, 30, 200)
ballIsHeld = drawFielder(HomeTeamSprites, ball, 350, 200, playdate.graphics.kImageFlippedX) or ballIsHeld
-- drawFielder(AwayTeamSprites, { x = 0, y = 0, z = 0 }, ball.x, ball.y)
if not ballIsHeld then
gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(ball.x, ball.y, ball.size)
gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(ball.x, ball.y, ball.size)
end
end

View File

@ -27,6 +27,8 @@ import 'draw/player.lua'
import 'draw/overlay.lua' import 'draw/overlay.lua'
import 'draw/fielder.lua' import 'draw/fielder.lua'
import 'main-menu.lua'
import 'action-queue.lua' import 'action-queue.lua'
import 'announcer.lua' import 'announcer.lua'
import 'ball.lua' import 'ball.lua'
@ -37,6 +39,8 @@ import 'graphics.lua'
import 'npc.lua' import 'npc.lua'
-- stylua: ignore end -- stylua: ignore end
-- TODO: Customizable field structure. E.g. stands and ads etc.
-- selene: allow(shadowing) -- selene: allow(shadowing)
local gfx <const>, C <const> = playdate.graphics, C local gfx <const>, C <const> = playdate.graphics, C
@ -82,9 +86,9 @@ local secondsSincePitchAllowed = 0
-- These are only sort-of global state. They are purely graphical, -- These are only sort-of global state. They are purely graphical,
-- but they need to be kept in sync with the rest of the globals. -- but they need to be kept in sync with the rest of the globals.
local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper local runnerBlipper
local battingTeamSprites = AwayTeamSprites local battingTeamSprites
local fieldingTeamSprites = HomeTeamSprites local fieldingTeamSprites
------------------------- -------------------------
-- END OF GLOBAL STATE -- -- END OF GLOBAL STATE --
@ -455,7 +459,7 @@ end
-- TODO: Swappable update() for main menu, etc. -- TODO: Swappable update() for main menu, etc.
function playdate.update() function mainGameUpdate()
playdate.timer.updateTimers() playdate.timer.updateTimers()
gfx.animation.blinker.updateAll() gfx.animation.blinker.updateAll()
updateGameState() updateGameState()
@ -517,13 +521,8 @@ function playdate.update()
end end
end end
local function init() local function mainGameInit()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
fielding:resetFielderPositions(teams.home.benchPosition) fielding:resetFielderPositions(teams.home.benchPosition)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false) launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end) end)
@ -531,6 +530,27 @@ local function init()
BootTune:setFinishCallback(function() BootTune:setFinishCallback(function()
TinnyBackground:play() TinnyBackground:play()
end) end)
battingTeamSprites = AwayTeamSprites
fieldingTeamSprites = HomeTeamSprites
HomeTeamBlipper = blipper.new(100, HomeTeamSprites.smiling, HomeTeamSprites.lowHat)
AwayTeamBlipper = blipper.new(100, AwayTeamSprites.smiling, AwayTeamSprites.lowHat)
runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
end
local function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
-- TODO: A lot of stuff ends up hinky here, because animators are ticking from the moment they initialize.
-- TODO: Much needs to be redesigned to only init when a game is *actually* starting.
-- MainMenu.start({
-- mainGameUpdateFunction = mainGameUpdate,
-- mainGameInitFunction = mainGameInit,
-- })
playdate.update = mainGameUpdate
mainGameInit()
end end
init() init()

View File

@ -164,6 +164,22 @@ function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, li
return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2) return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
end end
--- Returns true if the given point is anywhere above the given line, with no upper bound.
--- This, if used for home run calculations, would not take into account balls that curve around the foul poles.
---@param point XyPair
---@param linePoints XyPair[]
---@return boolean
function utils.pointIsSquarelyAboveLine(point, linePoints)
for i = 2, #linePoints do
local prev = linePoints[i - 1]
local next = linePoints[i]
if point.x >= prev.x and point.x <= next.x then
return not utils.pointUnderLine(point.x, point.y, prev.x, prev.y, next.x, next.y)
end
end
return false
end
--- Returns true only if the point is below the given line. --- Returns true only if the point is below the given line.
---@return boolean ---@return boolean
function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2) function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)