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)
actionQueue = {
---@type ({ action: Action, expireTimeMs: number })[]
---@type table<any, { coroutine: thread, expireTimeMs: number }>
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.
--- 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.
---@param name string
---@param id any
---@param maxTimeMs number
---@param action Action
function actionQueue:upsert(name, maxTimeMs, action)
if action(0) ~= ActionResult.NeedsMoreTime then
return
function actionQueue:upsert(id, maxTimeMs, action)
if self.queue[id] then
close(self.queue[id].coroutine)
end
self.queue[name] = {
action = action,
self.queue[id] = {
coroutine = coroutine.create(action),
expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(),
}
end
@ -39,10 +31,16 @@ end
--- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired.
function actionQueue:runWaiting(deltaSeconds)
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
for name, actionObject in pairs(self.queue) do
local result = actionObject.action(deltaSeconds)
if result ~= ActionResult.NeedsMoreTime or currentTimeMs > actionObject.expireTimeMs then
self.queue[name] = nil
for id, actionObject in pairs(self.queue) do
coroutine.resume(actionObject.coroutine, deltaSeconds)
if currentTimeMs > actionObject.expireTimeMs then
close(actionObject.coroutine)
end
if coroutine.status(actionObject.coroutine) == "dead" then
self.queue[id] = nil
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")
--selene: allow(unused_variable)
--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")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
@ -57,30 +60,31 @@ TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav")
Logos = {
--selene: allow(unused_variable)
--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(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(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(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(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(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(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(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(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"
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.
local p = io.popen('find src/' .. dir .. ' -type f -maxdepth 1')
@ -11,7 +14,7 @@
file = file:gsub("src/", "")
assetCode = assetCode .. '--selene: allow(unused_variable)\n'
assetCode = assetCode .. '--selene: allow(unscoped_variables)\n'
assetCode = assetCode .. varName .. ' = ' .. newFunc .. '("' .. file .. '")' .. sep
assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
end
end
return assetCode
@ -27,5 +30,11 @@ end)!!(generatedFileWarning())
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
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]
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
return dbg
end

View File

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

View File

@ -15,6 +15,7 @@ function maybeDrawInverted(image, x, y, drawInverted)
gfx.setImageDrawMode(drawMode)
end
--- TODO: Custom names on jerseys?
---@return SpriteCollection
---@param base pd_image
---@param isDark boolean
@ -52,7 +53,20 @@ function buildCollection(base, back, logo, isDark)
end
--selene: allow(unscoped_variables)
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, Logos.Base, true)
---@type SpriteCollection
AwayTeamSprites = nil
--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,19 +153,22 @@ end
---@param targetBase Base
---@param launchBall LaunchBall
---@param throwFlyMs number
---@return ActionResult
local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs)
if field.fielderHoldingBall == nil then
return ActionResult.NeedsMoreTime
end
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
end)
local function userThrowToCoroutine(field, targetBase, launchBall, throwFlyMs)
while true do
if field.fielderHoldingBall == nil then
coroutine.yield()
else
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
end)
closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return ActionResult.Succeeded
closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return
end
end
end
--- Buffer in a fielder throw action.
@ -176,7 +179,7 @@ end
function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
local maxTryTimeMs = 5000
actionQueue:upsert("userThrowTo", maxTryTimeMs, function()
return userThrowToImpl(self, targetBase, launchBall, throwFlyMs)
userThrowToCoroutine(self, targetBase, launchBall, throwFlyMs)
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/fielder.lua'
import 'main-menu.lua'
import 'action-queue.lua'
import 'announcer.lua'
import 'ball.lua'
@ -37,6 +39,8 @@ import 'graphics.lua'
import 'npc.lua'
-- stylua: ignore end
-- TODO: Customizable field structure. E.g. stands and ads etc.
-- selene: allow(shadowing)
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,
-- but they need to be kept in sync with the rest of the globals.
local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
local battingTeamSprites = AwayTeamSprites
local fieldingTeamSprites = HomeTeamSprites
local runnerBlipper
local battingTeamSprites
local fieldingTeamSprites
-------------------------
-- END OF GLOBAL STATE --
@ -455,7 +459,7 @@ end
-- TODO: Swappable update() for main menu, etc.
function playdate.update()
function mainGameUpdate()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
updateGameState()
@ -517,13 +521,8 @@ function playdate.update()
end
end
local function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
local function mainGameInit()
fielding:resetFielderPositions(teams.home.benchPosition)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function()
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
@ -531,6 +530,27 @@ local function init()
BootTune:setFinishCallback(function()
TinnyBackground:play()
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
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)
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.
---@return boolean
function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)