This commit is contained in:
Sage Vaillancourt 2025-02-20 13:52:22 -05:00
commit 35c7754207
34 changed files with 1852 additions and 624 deletions

4
.luacheckrc Normal file
View File

@ -0,0 +1,4 @@
std = "lua54+playdate"
stds.project = {
read_globals = {"playdate"}
}

View File

@ -1,5 +1,3 @@
SOURCE_FILES := $(shell grep "import '" src/main.lua | grep -v CoreLibs | sed "s/.*'\(.*\)'.*/src\/\1/") src/main.lua
all: all:
pdc --skip-unknown src BatterUp.pdx pdc --skip-unknown src BatterUp.pdx
@ -8,7 +6,7 @@ assets:
check: assets check: assets
stylua -c --indent-type Spaces src/ stylua -c --indent-type Spaces src/
cat __stub.ext.lua <(sed 's/^function/-- selene: allow(unused_variable)\nfunction/' ${PLAYDATE_SDK_PATH}/CoreLibs/__types.lua) ${SOURCE_FILES} | grep -v '^import' | sed 's/<const>//g' | selene - luacheck -d --codes src/ --exclude-files src/test/
test: check test: check
(cd src; find ./test -name '*lua' | xargs -L1 lua) (cd src; find ./test -name '*lua' | xargs -L1 lua)

View File

@ -1,36 +1,26 @@
---@alias ActionResult {}
---@type table<string, ActionResult>
-- selene: allow(unscoped_variables)
ActionResult = {
Succeeded = {},
Failed = {},
NeedsMoreTime = {},
}
-- 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)
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 +29,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

@ -1,4 +1,3 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics local gfx = playdate.graphics
local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft") local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft")
@ -10,7 +9,6 @@ local AnnouncerAnimatorInY <const> =
local AnnouncerAnimatorOutY <const> = local AnnouncerAnimatorOutY <const> =
playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint) playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint)
-- selene: allow(unscoped_variables)
---@class Announcer ---@class Announcer
---@field textQueue string[] ---@field textQueue string[]
---@field animatorY pd_animator ---@field animatorY pd_animator
@ -46,6 +44,7 @@ function Announcer:popIn()
end) end)
end end
---@param text string
function Announcer:say(text) function Announcer:say(text)
self.textQueue[#self.textQueue + 1] = text self.textQueue[#self.textQueue + 1] = text
if #self.textQueue == 1 then if #self.textQueue == 1 then

View File

@ -1,86 +1,67 @@
-- GENERATED FILE - DO NOT EDIT -- GENERATED FILE - DO NOT EDIT
-- Instead, edit the source file directly: assets.lua2p. -- Instead, edit the source file directly: assets.lua2p.
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png") DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
Glove = playdate.graphics.image.new("images/game/Glove.png") Glove = playdate.graphics.image.new("images/game/Glove.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png") PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables) BigBat = playdate.graphics.image.new("images/game/BigBat.png")
-- luacheck: ignore
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png") GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables) GameLogo = playdate.graphics.image.new("images/game/GameLogo.png")
-- luacheck: ignore
Hat = playdate.graphics.image.new("images/game/Hat.png") Hat = playdate.graphics.image.new("images/game/Hat.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
DarkPlayerBase = playdate.graphics.image.new("images/game/DarkPlayerBase.png") DarkPlayerBase = playdate.graphics.image.new("images/game/DarkPlayerBase.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
MenuImage = playdate.graphics.image.new("images/game/MenuImage.png") MenuImage = playdate.graphics.image.new("images/game/MenuImage.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
PlayerSmile = playdate.graphics.image.new("images/game/PlayerSmile.png") PlayerSmile = playdate.graphics.image.new("images/game/PlayerSmile.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
Minimap = playdate.graphics.image.new("images/game/Minimap.png") Minimap = playdate.graphics.image.new("images/game/Minimap.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png") GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables) GrassBackgroundSmall = playdate.graphics.image.new("images/game/GrassBackgroundSmall.png")
-- luacheck: ignore
LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png") LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png") LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav") BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav") BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav") BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables)
TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav") TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav")
-- luacheck: ignore
MenuMusic = playdate.sound.sampleplayer.new("music/MenuMusic.wav")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Logos = { Logos = {
--selene: allow(unused_variable) { name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
--selene: allow(unscoped_variables)
Cats = playdate.graphics.image.new("images/game/logos/Cats.png"), -- luacheck: ignore
--selene: allow(unused_variable) { name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") },
--selene: allow(unscoped_variables) -- luacheck: ignore
Hearts = playdate.graphics.image.new("images/game/logos/Hearts.png"), { name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") },
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables) { name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") },
Checkmarks = playdate.graphics.image.new("images/game/logos/Checkmarks.png"), -- luacheck: ignore
--selene: allow(unused_variable) { name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") },
--selene: allow(unscoped_variables) -- luacheck: ignore
Smiles = playdate.graphics.image.new("images/game/logos/Smiles.png"), { name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") },
--selene: allow(unused_variable) -- luacheck: ignore
--selene: allow(unscoped_variables) { name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") },
FingerGuns = playdate.graphics.image.new("images/game/logos/FingerGuns.png"), -- luacheck: ignore
--selene: allow(unused_variable) { name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") },
--selene: allow(unscoped_variables) -- luacheck: ignore
Base = playdate.graphics.image.new("images/game/logos/Base.png"), { name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.png") },
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Frown = 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"),
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Turds = playdate.graphics.image.new("images/game/logos/Turds.png"),
} }

View File

@ -1,7 +1,10 @@
!(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 .. ' -maxdepth 1 -type f')
local assetCode = "" local assetCode = ""
--Loop through all files --Loop through all files
@ -9,9 +12,8 @@
if file:find(extension) then if file:find(extension) then
local varName = file:gsub(".*/(.*)." .. extension, "%1") local varName = file:gsub(".*/(.*)." .. extension, "%1")
file = file:gsub("src/", "") file = file:gsub("src/", "")
assetCode = assetCode .. '--selene: allow(unused_variable)\n' assetCode = assetCode .. '-- luacheck: ignore\n'
assetCode = assetCode .. '--selene: allow(unscoped_variables)\n' assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
assetCode = assetCode .. varName .. ' = ' .. newFunc .. '("' .. file .. '")' .. sep
end end
end end
return assetCode return assetCode
@ -24,8 +26,10 @@ end)!!(generatedFileWarning())
!!(dirLookup('images/game', 'png', 'playdate.graphics.image.new')) !!(dirLookup('images/game', 'png', 'playdate.graphics.image.new'))
!!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new')) !!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new'))
!!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new')) !!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new'))
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Logos = { Logos = {
!!(dirLookup('images/game/logos', 'png', 'playdate.graphics.image.new', ",\n")) { 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

@ -1,16 +1,15 @@
-- selene: allow(unscoped_variables)
---@class Ball ---@class Ball
---@field x number ---@field x number
---@field y number ---@field y number
---@field z number ---@field z number
---@field size number ---@field size number
---@field heldBy Fielder | nil ---@field heldBy Fielder | nil
---@field catchable boolean
---@field xAnimator SimpleAnimator ---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator ---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator
---@field floatAnimator SimpleAnimator ---@field floatAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib ---@field private animatorLib pd_animator_lib
---@field private flyTimeMs number
Ball = {} Ball = {}
---@param animatorLib pd_animator_lib ---@param animatorLib pd_animator_lib
@ -21,6 +20,7 @@ function Ball.new(animatorLib)
x = C.Center.x --[[@as number]], x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]], y = C.Center.y --[[@as number]],
z = 0, z = 0,
catchable = true,
size = C.SmallestBallRadius, size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]], heldBy = nil --[[@type Runner | nil]],
@ -50,6 +50,13 @@ function Ball:updatePosition()
end end
end end
function Ball:markUncatchable()
self.catchable = false
playdate.timer.new(200, function()
self.catchable = true
end)
end
--- Launches the ball from its current position to the given destination. --- Launches the ball from its current position to the given destination.
---@param destX number ---@param destX number
---@param destY number ---@param destY number
@ -58,18 +65,19 @@ end
---@param floaty boolean | nil ---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil ---@param customBallScaler pd_animator | nil
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
throwMeter:reset()
self.heldBy = nil self.heldBy = nil
-- Prevent silly insta-catches
self:markUncatchable()
if not flyTimeMs then if not flyTimeMs then
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
end end
self.flyTimeMs = flyTimeMs
if customBallScaler then if customBallScaler then
self.sizeAnimator = customBallScaler self.sizeAnimator = customBallScaler
else else
-- TODO? Scale based on distance?
self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill) self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
end end
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc) self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)

View File

@ -6,18 +6,20 @@
--- forcedTo: Base | nil, --- forcedTo: Base | nil,
--- } --- }
-- selene: allow(unscoped_variables)
---@class Baserunning ---@class Baserunning
---@field runners Runner[] ---@field runners Runner[]
---@field outRunners Runner[] ---@field outRunners Runner[]
---@field scoredRunners Runner[] ---@field scoredRunners Runner[]
---@field batter Runner | nil ---@field batter Runner | nil
---@field outs number ---@field outs number
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
---@field secondsSinceLastRunnerMove number
---@field announcer Announcer ---@field announcer Announcer
---@field onThirdOut fun() ---@field onThirdOut fun()
Baserunning = {} Baserunning = {}
-- TODO: Implement slides. Would require making fielders' gloves "real objects" whose state is tracked. -- TODO: Implement slides? Would require making fielders' gloves "real objects" whose state is tracked.
-- TODO: Don't allow runners to occupy the same base!
---@param announcer Announcer ---@param announcer Announcer
---@return Baserunning ---@return Baserunning
@ -39,7 +41,7 @@ function Baserunning.new(announcer, onThirdOut)
return o return o
end end
---@param runner integer | Runner ---@param runner number | Runner
---@param message string | nil ---@param message string | nil
---@return boolean wasThirdOut ---@return boolean wasThirdOut
function Baserunning:outRunner(runner, message) function Baserunning:outRunner(runner, message)
@ -66,7 +68,6 @@ function Baserunning:outRunner(runner, message)
self.onThirdOut() self.onThirdOut()
self.outs = 0 self.outs = 0
-- TODO: outRunners/scoredRunners split
while #self.runners > 0 do while #self.runners > 0 do
self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners) self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
end end
@ -125,16 +126,27 @@ function Baserunning:convertBatterToRunner()
self.batter = nil -- Demote batter to a mere runner self.batter = nil -- Demote batter to a mere runner
end end
---@param deltaSeconds number local function walkWayOutRunner(deltaSeconds, runner)
function Baserunning:walkAwayOutRunners(deltaSeconds)
for i, runner in ipairs(self.outRunners) do
if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
runner.x = runner.x + (deltaSeconds * 25) runner.x = runner.x + (deltaSeconds * 25)
runner.y = runner.y + (deltaSeconds * 25) runner.y = runner.y + (deltaSeconds * 25)
else return true
end
return false
end
---@param deltaSeconds number
function Baserunning:walkAwayOutRunners(deltaSeconds)
for i, runner in ipairs(self.outRunners) do
if not walkWayOutRunner(deltaSeconds, runner) then
table.remove(self.outRunners, i) table.remove(self.outRunners, i)
end end
end end
for i, runner in ipairs(self.scoredRunners) do
if not walkWayOutRunner(deltaSeconds, runner) then
table.remove(self.scoredRunners, i)
end
end
end end
---@return Runner ---@return Runner
@ -153,16 +165,16 @@ function Baserunning:pushNewBatter()
end end
---@param self table ---@param self table
---@param runnerIndex integer ---@param runnerIndex number
function Baserunning:runnerScored(runnerIndex) function Baserunning:runnerScored(runnerIndex)
-- TODO: outRunners/scoredRunners split -- TODO: outRunners/scoredRunners split
self.outRunners[#self.outRunners + 1] = self.runners[runnerIndex] self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex]
table.remove(self.runners, runnerIndex) table.remove(self.runners, runnerIndex)
end end
--- Returns true only if the given runner moved during this update. --- Returns true only if the given runner moved during this update.
---@param runner Runner | nil ---@param runner Runner | nil
---@param runnerIndex integer | nil May only be nil if runner == batter ---@param runnerIndex number | nil May only be nil if runner == batter
---@param appliedSpeed number ---@param appliedSpeed number
---@param deltaSeconds number ---@param deltaSeconds number
---@return boolean runnerMoved, boolean runnerScored ---@return boolean runnerMoved, boolean runnerScored
@ -227,9 +239,9 @@ end
--- Update non-batter runners. --- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update --- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved, number runnersScored ---@return boolean runnersStillMoving, number runnersScored, number secondsSinceLastMove
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
local someRunnerMoved = false local runnersStillMoving = false
local runnersScored = 0 local runnersScored = 0
local speedIsFunction = type(appliedSpeed) == "function" local speedIsFunction = type(appliedSpeed) == "function"
@ -241,20 +253,24 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon
speed = appliedSpeed(runner) speed = appliedSpeed(runner)
end end
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds) local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
someRunnerMoved = someRunnerMoved or thisRunnerMoved runnersStillMoving = runnersStillMoving or thisRunnerMoved
if thisRunnerScored then if thisRunnerScored then
runnersScored = runnersScored + 1 runnersScored = runnersScored + 1
end end
end end
end end
if someRunnerMoved then if runnersStillMoving then
self.secondsSinceLastRunnerMove = 0
self:updateForcedRunners() self:updateForcedRunners()
else
self.secondsSinceLastRunnerMove = (self.secondsSinceLastRunnerMove or 0) + deltaSeconds
end end
return someRunnerMoved, runnersScored return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove
end end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then if not playdate or playdate.TEST_MODE then
return Baserunning return Baserunning
end end

View File

@ -1,4 +1,3 @@
-- selene: allow(unscoped_variables)
C = {} C = {}
C.Screen = { C.Screen = {
@ -31,7 +30,7 @@ C.FieldHeight = C.Bases[C.Home].y - C.Bases[C.Second].y
-- Pseudo-base for batter to target -- Pseudo-base for batter to target
C.RightHandedBattersBox = { C.RightHandedBattersBox = {
x = C.Bases[C.Home].x - 30, x = C.Bases[C.Home].x - 30,
y = C.Bases[C.Home].y, y = C.Bases[C.Home].y + 10,
} }
---@type table<Base, Base | nil> ---@type table<Base, Base | nil>
@ -71,8 +70,8 @@ C.BallOffscreen = 999
C.PitchAfterSeconds = 6 C.PitchAfterSeconds = 6
C.ReturnToPitcherAfterSeconds = 2.4 C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050 C.PitchFlyMs = 1050
C.PitchStartX = 195 C.PitchStart = utils.xy(195, 105)
C.PitchStartY, C.PitchEndY = 105, 240 C.PitchEndY = 240
C.DefaultLaunchPower = 4 C.DefaultLaunchPower = 4
@ -92,33 +91,23 @@ C.SmallestBallRadius = 6
C.BatLength = 35 C.BatLength = 35
-- TODO: enums implemented this way are probably going to be difficult to serialize! ---@alias OffenseState "batting" | "running" | "walking" | "homeRun"
---@alias OffenseState table
--- An enum for what state the offense is in --- An enum for what state the offense is in
---@type table<string, OffenseState> ---@type table<string, OffenseState>
C.Offense = { C.Offense = {
batting = {}, batting = "batting",
running = {}, running = "running",
walking = {}, walking = "walking",
homeRun = "homeRun",
} }
---@alias Side table ---@alias Side "offense" | "defense"
--- An enum for which side (offense or defense) a team is on.
---@type table<string, Side>
C.Sides = {
offense = {},
defense = {},
}
C.PitcherStartPos = { C.PitcherStartPos = {
x = C.Screen.W * 0.48, x = C.Screen.W * 0.48,
y = C.Screen.H * 0.40, y = C.Screen.H * 0.40,
} }
C.ThrowMeterMax = 10
C.ThrowMeterDrainPerSec = 150
--- Controls how hard the ball can be hit, and --- Controls how hard the ball can be hit, and
--- how fast the ball can be thrown. --- how fast the ball can be thrown.
C.CrankPower = 10 C.CrankPower = 10
@ -132,6 +121,24 @@ C.WalkedRunnerSpeed = 10
C.ResetFieldersAfterSeconds = 2.5 C.ResetFieldersAfterSeconds = 2.5
C.OutfieldWall = {
{ x = -400, y = -103 },
{ x = -167, y = -208 },
{ x = 50, y = -211 },
{ x = 150, y = -181 },
{ x = 339, y = -176 },
{ x = 450, y = -221 },
{ x = 700, y = -209 },
{ x = 785, y = -59 },
{ x = 801, y = -16 },
}
C.BottomOfOutfieldWall = {}
for i, v in ipairs(C.OutfieldWall) do
C.BottomOfOutfieldWall[i] = utils.xy(v.x, v.y + 40)
end
if not playdate then if not playdate then
return C return C
end end

View File

@ -1,7 +1,5 @@
-- selene: allow(unscoped_variables)
dbg = {} dbg = {}
-- selene: allow(unused_variable)
function dbg.label(value, name) function dbg.label(value, name)
if type(value) == "table" then if type(value) == "table" then
print(name .. ":") print(name .. ":")
@ -19,7 +17,6 @@ function dbg.label(value, name)
end end
-- Only works if called with the bases empty (i.e. the only runner should be the batter. -- Only works if called with the bases empty (i.e. the only runner should be the batter.
-- selene: allow(unused_variable)
function dbg.loadTheBases(br) function dbg.loadTheBases(br)
br:pushNewBatter() br:pushNewBatter()
br:pushNewBatter() br:pushNewBatter()
@ -38,6 +35,76 @@ function dbg.loadTheBases(br)
br.runners[4].nextBase = C.Bases[C.Home] br.runners[4].nextBase = C.Bases[C.Home]
end end
local hitSamples = {
away = {
{
utils.xy(7.88733, -16.3434),
utils.xy(378.3376, 30.49521),
utils.xy(367.1036, 21.55336),
},
{
utils.xy(379.8051, -40.82794),
utils.xy(-444.5791, -30.30901),
utils.xy(-30.43079, -30.50307),
},
{
utils.xy(227.8881, -14.56854),
utils.xy(293.5208, 39.38919),
utils.xy(154.4738, -26.55899),
},
},
home = {
{
utils.xy(146.2505, -89.12155),
utils.xy(429.5428, 59.62944),
utils.xy(272.4666, -78.578),
},
{
utils.xy(485.0516, 112.8341),
utils.xy(290.9232, -4.946442),
utils.xy(263.4262, -6.482407),
},
{
utils.xy(260.6927, -63.63049),
utils.xy(392.1548, -44.22421),
utils.xy(482.5545, 105.3476),
utils.xy(125.5928, 18.53091),
},
},
}
---@return Statistics
function dbg.mockStatistics(inningCount)
inningCount = inningCount or 9
local stats = Statistics.new()
for i = 1, inningCount - 1 do
stats.innings[i].home.score = math.floor(math.random() * 5)
stats.innings[i].away.score = math.floor(math.random() * 5)
if hitSamples.home[i] ~= nil then
stats.innings[i].home.hits = hitSamples.home[i]
end
if hitSamples.away[i] ~= nil then
stats.innings[i].away.hits = hitSamples.away[i]
end
stats:pushInning()
end
local homeScore, awayScore = utils.totalScores(stats)
if homeScore == awayScore then
stats.innings[#stats.innings].home.score = 1 + stats.innings[#stats.innings].home.score
end
return stats
end
---@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

248
src/draw/box-score.lua Normal file
View File

@ -0,0 +1,248 @@
---@alias TeamInningData { score: number, pitching: { balls: number, strikes: number }, hits: XyPair[] }
--- E.g. statistics[1].home.pitching.balls
---@class Statistics
---@field innings: (table<TeamId, TeamInningData>)[]
Statistics = {}
local function newTeamInning()
return {
score = 0,
pitching = {
balls = 0,
strikes = 0,
},
hits = {},
}
end
---@return table<TeamId, TeamInningData>
local function newInning()
return {
home = newTeamInning(),
away = newTeamInning(),
}
end
---@return Statistics
function Statistics.new()
return setmetatable({
innings = { newInning() },
}, { __index = Statistics })
end
function Statistics:pushInning()
self.innings[#self.innings + 1] = newInning()
end
---@class BoxScore
---@field stats Statistics
---@field private targetY number
BoxScore = {}
---@param stats Statistics
function BoxScore.new(stats)
return setmetatable({
stats = stats,
targetY = 0,
}, { __index = BoxScore })
end
-- TODO: Convert the box-score into a whole "scene"
-- * Scroll left and right through games that go into extra innings
-- * Scroll up and down through other stats.
-- + Balls and strikes
-- + Batting average
-- + Graph of team scores over time
-- + Farthest hit ball
local MarginY <const> = 70
local SmallFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft")
local ScoreFont <const> = playdate.graphics.font.new("fonts/Asheville-Sans-14-Bold.pft")
local NumWidth <const> = ScoreFont:getTextWidth("0")
local NumHeight <const> = ScoreFont:getHeight()
local AwayWidth <const> = ScoreFont:getTextWidth("AWAY")
local InningMargin = 4
local InningDrawWidth <const> = (InningMargin * 2) + (NumWidth * 2)
local ScoreDrawHeight = NumHeight * 2
-- luacheck: ignore 143
---@type pd_graphics_lib
local gfx = playdate.graphics
local function formatScore(n)
if n <= 9 then
return " " .. n
elseif n <= 19 then
return " " .. n
else
return tostring(n)
end
end
local HomeY <const> = -4 + (NumHeight * 2) + MarginY
local AwayY <const> = -4 + (NumHeight * 3) + MarginY
local function drawInning(x, inningNumber, homeScore, awayScore)
gfx.setColor(gfx.kColorBlack)
gfx.setColor(gfx.kColorWhite)
gfx.setLineWidth(1)
gfx.drawRect(x, 34 + MarginY, InningDrawWidth, ScoreDrawHeight)
inningNumber = " " .. inningNumber
homeScore = formatScore(homeScore)
awayScore = formatScore(awayScore)
x = x - 8 + (InningDrawWidth / 2)
ScoreFont:drawTextAligned(inningNumber, x, -4 + NumHeight + MarginY, gfx.kAlignRight)
ScoreFont:drawTextAligned(awayScore, x, HomeY, gfx.kAlignRight)
ScoreFont:drawTextAligned(homeScore, x, AwayY, gfx.kAlignRight)
end
function BoxScore:drawBoxScore()
local inningStart = 4 + (AwayWidth * 1.5)
local widthAndMarg = InningDrawWidth + 4
ScoreFont:drawTextAligned(" HOME", 10, HomeY, gfx.kAlignRight)
ScoreFont:drawTextAligned("AWAY", 10, AwayY, gfx.kAlignRight)
for i = 1, #self.stats.innings do
local inningStats = self.stats.innings[i]
drawInning(inningStart + ((i - 1) * widthAndMarg), i, inningStats.home.score, inningStats.away.score)
end
local homeScore, awayScore = utils.totalScores(self.stats)
drawInning(4 + inningStart + (widthAndMarg * #self.stats.innings), "F", homeScore, awayScore)
ScoreFont:drawTextAligned("v", C.Center.x, C.Screen.H - 40, gfx.kAlignCenter)
end
local GraphM = 10
local GraphW = C.Screen.W - (GraphM * 2)
local GraphH = C.Screen.H - (GraphM * 2)
function BoxScore:drawScoreGraph(y)
-- TODO: Actually draw score legend
-- Offset by 2 to support a) the zero-index b) the score legend
local segmentWidth = GraphW / (#self.stats.innings + 2)
local legendX = segmentWidth * (#self.stats.innings + 2) - GraphM
gfx.drawLine(GraphM / 2, y + GraphM + GraphH, legendX, y + GraphM + GraphH)
gfx.drawLine(legendX, y + GraphM, legendX, y + GraphH + GraphM)
gfx.setLineWidth(3)
local homeScore, awayScore = utils.totalScores(self.stats)
local highestScore = math.max(homeScore, awayScore)
local heightPerPoint = (GraphH - 6) / highestScore
function point(inning, score)
return utils.xy(GraphM + (inning * segmentWidth), y + GraphM + GraphH + (score * -heightPerPoint))
end
function drawLine(teamId)
local linePoints = { point(0, 0) }
local scoreTotal = 0
for i, inning in ipairs(self.stats.innings) do
scoreTotal = scoreTotal + inning[teamId].score
linePoints[#linePoints + 1] = point(i, scoreTotal)
end
dbg.drawLine(linePoints)
local finalPoint = linePoints[#linePoints]
SmallFont:drawTextAligned(string.upper(teamId), finalPoint.x + 3, finalPoint.y - 7, gfx.kAlignRight)
end
drawLine("home")
gfx.setDitherPattern(0.5)
drawLine("away")
gfx.setDitherPattern(0)
end
---@param realHit XyPair
---@return XyPair
function convertHitToMini(realHit)
-- Convert to all-positive y
local y = realHit.y + C.Screen.H
y = y / 2
local x = realHit.x + C.Screen.W
x = x / 3
return utils.xy(x, y)
end
function BoxScore:drawHitChart(y)
local leftMargin = 8
GrassBackgroundSmall:drawCentered(C.Center.x, y + C.Center.y + 54)
gfx.setLineWidth(1)
ScoreFont:drawTextAligned("AWAY", leftMargin, y + C.Screen.H - NumHeight, gfx.kAlignRight)
gfx.setColor(gfx.kColorBlack)
gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer2x2)
gfx.fillRect(leftMargin, y + C.Screen.H - NumHeight, ScoreFont:getTextWidth("AWAY"), NumHeight)
gfx.setColor(gfx.kColorWhite)
gfx.setDitherPattern(0.5)
for _, inning in ipairs(self.stats.innings) do
for _, hit in ipairs(inning.away.hits) do
local miniHitPos = convertHitToMini(hit)
gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4)
end
end
gfx.setColor(gfx.kColorWhite)
gfx.setDitherPattern(0)
ScoreFont:drawTextAligned(" HOME", leftMargin, y + C.Screen.H - (NumHeight * 2), gfx.kAlignRight)
for _, inning in ipairs(self.stats.innings) do
for _, hit in ipairs(inning.home.hits) do
local miniHitPos = convertHitToMini(hit)
gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4)
end
end
end
local screens = {
BoxScore.drawBoxScore,
BoxScore.drawScoreGraph,
BoxScore.drawHitChart,
}
function BoxScore:render()
local originalDrawMode = gfx.getImageDrawMode()
gfx.clear(gfx.kColorBlack)
gfx.setImageDrawMode(gfx.kDrawModeInverted)
gfx.setColor(gfx.kColorBlack)
for i, screen in ipairs(screens) do
screen(self, (i - 1) * C.Screen.H)
end
gfx.setImageDrawMode(originalDrawMode)
end
local renderedImage
function BoxScore:update()
if not renderedImage then
renderedImage = gfx.image.new(C.Screen.W, C.Screen.H * #screens)
gfx.pushContext(renderedImage)
self:render()
gfx.popContext()
end
local deltaSeconds = playdate.getElapsedTime()
playdate.resetElapsedTime()
gfx.setDrawOffset(0, self.targetY)
renderedImage:draw(0, 0)
local crankChange = playdate.getCrankChange()
if crankChange ~= 0 then
self.targetY = self.targetY - (crankChange * 0.8)
else
local closestScreen = math.floor(0.5 + (self.targetY / C.Screen.H)) * C.Screen.H
if math.abs(self.targetY - closestScreen) > 3 then
local needsIncrease = self.targetY < closestScreen
local change = needsIncrease and 200 * deltaSeconds or -200 * deltaSeconds
self.targetY = self.targetY + change
end
end
self.targetY = math.max(math.min(self.targetY, 0), -C.Screen.H * (#screens - 1))
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

@ -83,11 +83,10 @@ local ScoreboardHeight <const> = 55
local Indicator = "> " local Indicator = "> "
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator) local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
---@param teams any
---@param battingTeam any ---@param battingTeam any
---@return string, number, string, number ---@return string, number, string, number
function getIndicators(teams, battingTeam) function getIndicators(battingTeam)
if teams.home == battingTeam then if battingTeam == "home" then
return Indicator, 0, "", IndicatorWidth return Indicator, 0, "", IndicatorWidth
end end
return "", IndicatorWidth, Indicator, 0 return "", IndicatorWidth, Indicator, 0
@ -101,11 +100,11 @@ local stats = {
battingTeam = nil, battingTeam = nil,
} }
function drawScoreboardImpl(x, y, teams) function drawScoreboardImpl(x, y)
local homeScore = stats.homeScore local homeScore = stats.homeScore
local awayScore = stats.awayScore local awayScore = stats.awayScore
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, stats.battingTeam) local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(stats.battingTeam)
local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore) local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore)
local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore) local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
@ -146,17 +145,17 @@ end
local newStats = stats local newStats = stats
function drawScoreboard(x, y, teams, outs, battingTeam, inning) function drawScoreboard(x, y, homeScore, awayScore, outs, battingTeam, inning)
if if
newStats.homeScore ~= teams.home.score newStats.homeScore ~= homeScore
or newStats.awayScore ~= teams.away.score or newStats.awayScore ~= awayScore
or newStats.outs ~= outs or newStats.outs ~= outs
or newStats.inning ~= inning or newStats.inning ~= inning
or newStats.battingTeam ~= battingTeam or newStats.battingTeam ~= battingTeam
then then
newStats = { newStats = {
homeScore = teams.home.score, homeScore = homeScore,
awayScore = teams.away.score, awayScore = awayScore,
outs = outs, outs = outs,
inning = inning, inning = inning,
battingTeam = battingTeam, battingTeam = battingTeam,
@ -165,5 +164,5 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning)
stats = newStats stats = newStats
end) end)
end end
drawScoreboardImpl(x, y, teams) drawScoreboardImpl(x, y)
end end

52
src/draw/panner.lua Normal file
View File

@ -0,0 +1,52 @@
---@class Panner
Panner = {}
local function panCoroutine(ball)
local offset = utils.xy(getDrawOffset(ball.x, ball.y))
while true do
local target, deltaSeconds = coroutine.yield(offset.x, offset.y)
if target == nil then
offset = utils.xy(getDrawOffset(ball.x, ball.y))
else
while utils.moveAtSpeed(offset, 200 * deltaSeconds, target, 20) do
target, deltaSeconds = coroutine.yield(offset.x, offset.y)
end
-- -- Pan back to ball
-- while utils.moveAtSpeed(offset, 200 * deltaSeconds, ball, 20) do
-- target, deltaSeconds = coroutine.yield(offset.x, offset.y)
-- end
end
end
end
---@param ball XyPair
function Panner.new(ball)
return setmetatable({
coroutine = coroutine.create(function()
panCoroutine(ball)
end),
panTarget = nil,
}, { __index = Panner })
end
---@param deltaSeconds number
---@return number offsetX, number offsetY
function Panner:get(deltaSeconds)
if self.holdUntil and self.holdUntil() then
self:reset()
end
local _, offsetX, offsetY = coroutine.resume(self.coroutine, self.panTarget, deltaSeconds)
return offsetX, offsetY
end
---@param panTarget XyPair
---@param holdUntil fun(): boolean
function Panner:panTo(panTarget, holdUntil)
self.panTarget = panTarget
self.holdUntil = holdUntil
end
function Panner:reset()
self.holdUntil = nil
self.panTarget = nil
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)

38
src/draw/throw-meter.lua Normal file
View File

@ -0,0 +1,38 @@
---@type pd_graphics_lib
local gfx <const> = playdate.graphics
local ThrowMeterHeight <const> = 50
local ThrowMeterLingerSec <const> = 1.5
---@param x number
---@param y number
function throwMeter:draw(x, y)
gfx.setLineWidth(1)
gfx.drawRect(x, y, 14, ThrowMeterHeight)
if self.lastReadThrow then
-- TODO: If ratio is "perfect", show some additional effect
-- TODO: If meter has moved to a new fielder, empty it.
local ratio = (self.lastReadThrow - throwMeter.MinCharge) / (self.idealPower - throwMeter.MinCharge)
local height = ThrowMeterHeight * ratio
gfx.fillRect(x + 2, y + ThrowMeterHeight - height, 10, height)
end
-- TODO: Dither or bend if the user throws too hard
-- Or maybe dither if it's too soft - bend if it's too hard
end
function throwMeter:drawNearFielder(fielder)
if not fielder and not self.lastThrower then
return
end
if fielder then
self.lastThrower = fielder
actionQueue:upsert("throwMeterLinger", 200 + ThrowMeterLingerSec * 1000, function()
local dt = 0
while dt < ThrowMeterLingerSec do
dt = dt + coroutine.yield()
end
self.lastThrower = nil
end)
end
self:draw(self.lastThrower.x - 25, self.lastThrower.y - 10)
end

92
src/draw/transitions.lua Normal file
View File

@ -0,0 +1,92 @@
Transitions = {
---@type Scene | nil
nextScene = nil,
---@type Scene | nil
previousScene = nil,
}
local gfx = playdate.graphics
local previousSceneImage
local previousSceneMask
local nextSceneImage
local batImageTable = {}
local batOffset = 80
local degStep = 3
function loadBatImageTable()
for deg = 90 - (degStep * 3), 270 + (degStep * 3), degStep do
local img = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(img)
BigBat:drawRotated(C.Center.x, C.Screen.H + batOffset, 90 + deg)
gfx.popContext()
batImageTable[deg] = img
end
end
loadBatImageTable()
local function update()
local lastAngle
local seamAngle = math.rad(270)
while seamAngle > math.rad(90) do
local deltaSeconds = playdate.getElapsedTime()
playdate.resetElapsedTime()
seamAngle = seamAngle - (deltaSeconds * 3)
local seamAngleDeg = math.floor(math.deg(seamAngle))
seamAngleDeg = seamAngleDeg - (seamAngleDeg % degStep)
-- Skip re-drawing if no change
if lastAngle ~= seamAngleDeg then
lastAngle = seamAngleDeg
nextSceneImage:draw(0, 0)
gfx.pushContext(previousSceneMask)
gfx.setImageDrawMode(gfx.kDrawModeFillBlack)
batImageTable[seamAngleDeg]:draw(0, 0)
gfx.popContext()
previousSceneImage:setMaskImage(previousSceneMask)
previousSceneImage:draw(0, 0)
batImageTable[seamAngleDeg]:draw(0, 0)
end
coroutine.yield()
end
playdate.update = function()
Transitions.nextScene:update()
end
end
---@param nextScene fun() The next playdate.update function
function transitionTo(nextScene)
if not Transitions.nextScene then
error("Expected Transitions to already have nextScene defined! E.g. by calling transitionBetween")
end
local previousScene = Transitions.nextScene
transitionBetween(previousScene, nextScene)
end
---@param previousScene Scene Has the current playdate.update function
---@param nextScene Scene Has the next playdate.update function
function transitionBetween(previousScene, nextScene)
playdate.wait(2) -- TODO: There's some sort of timing wack here.
playdate.update = update
previousSceneImage = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(previousSceneImage)
previousScene:update()
gfx.popContext()
nextSceneImage = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(nextSceneImage)
nextScene:update()
gfx.popContext()
previousSceneMask = gfx.image.new(C.Screen.W, C.Screen.H, gfx.kColorWhite)
previousSceneImage:setMaskImage(previousSceneMask)
Transitions.nextScene = nextScene
Transitions.previousScene = previousScene
end

View File

@ -1,19 +1,26 @@
--- @class Fielder { --- @class Fielder {
--- @field catchEligible boolean
--- @field x number --- @field x number
--- @field y number --- @field y number
--- @field target XyPair | nil --- @field target XyPair | nil
--- @field speed number --- @field speed number
-- TODO: Run down baserunners in a pickle. ---@class Fielders
---@field first Fielder
---@field second Fielder
---@field shortstop Fielder
---@field third Fielder
---@field pitcher Fielder
---@field catcher Fielder
---@field left Fielder
---@field center Fielder
---@field right Fielder
-- selene: allow(unscoped_variables)
---@class Fielding ---@class Fielding
---@field fielders table<string, Fielder> ---@field fielders Fielders
---@field fielderHoldingBall Fielder | nil ---@field fielderHoldingBall Fielder | nil
Fielding = {} Fielding = {}
local FielderDanceAnimator <const> = playdate.graphics.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1 FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1
---@param name string ---@param name string
@ -23,7 +30,6 @@ local function newFielder(name, speed)
return { return {
name = name, name = name,
speed = speed * C.FielderRunMult, speed = speed * C.FielderRunMult,
catchEligible = true,
} }
end end
@ -79,45 +85,54 @@ end
---@param deltaSeconds number ---@param deltaSeconds number
---@param fielder Fielder ---@param fielder Fielder
---@param ballPos XyPair ---@param ball Ball
---@return boolean inCatchingRange ---@return boolean canCatch
local function updateFielderPosition(deltaSeconds, fielder, ballPos) local function updateFielderPosition(deltaSeconds, fielder, ball)
if fielder.target ~= nil then if fielder.target ~= nil then
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then if
utils.pointIsSquarelyAboveLine(fielder, C.BottomOfOutfieldWall)
or not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target)
then
fielder.target = nil fielder.target = nil
end end
end end
return utils.distanceBetweenPoints(fielder, ballPos) < C.BallCatchHitbox return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox
end end
-- TODO: Prevent multiple fielders covering the same base.
-- At least in a how-about-everybody-stand-right-here way.
--- Selects the nearest fielder to move toward the given coordinates. --- Selects the nearest fielder to move toward the given coordinates.
--- Other fielders should attempt to cover their bases --- Other fielders should attempt to cover their bases
---@param self table
---@param ballDestX number ---@param ballDestX number
---@param ballDestY number ---@param ballDestY number
function Fielding:haveSomeoneChase(ballDestX, ballDestY) function Fielding:haveSomeoneChase(ballDestX, ballDestY)
local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY) local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY)
chasingFielder.target = { x = ballDestX, y = ballDestY } chasingFielder.target = utils.xy(ballDestX, ballDestY)
for _, base in ipairs(C.Bases) do for _, base in ipairs(C.Bases) do
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder) local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
-- For now, skipping the pitcher because they're considered closer to 2B than second or shortstop -- Skip the pitcher for 2B - they're considered closer than second or shortstop.
return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then
return false
end
return fielder ~= chasingFielder
end) end)
nearest.target = base nearest.target = base
end end
end end
--- **Also updates `ball.heldby`**
---@param ball Ball ---@param ball Ball
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball ---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
function Fielding:updateFielderPositions(ball, deltaSeconds) function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderHoldingBall = nil local fielderHoldingBall
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball)
if inCatchingRange and fielder.catchEligible then
-- TODO: Base this catch on fielder skill? -- TODO: Base this catch on fielder skill?
local canCatch = updateFielderPosition(deltaSeconds, fielder, ball)
if canCatch then
fielderHoldingBall = fielder fielderHoldingBall = fielder
ball.heldBy = fielder -- How much havoc will this wreak? ball.heldBy = fielder -- How much havoc will this wreak?
end end
@ -129,73 +144,45 @@ function Fielding:updateFielderPositions(ball, deltaSeconds)
return fielderHoldingBall return fielderHoldingBall
end end
---@param fielder Fielder
function Fielding.markIneligible(fielder)
fielder.catchEligible = false
playdate.timer.new(500, function()
fielder.catchEligible = true
end)
end
function Fielding:markAllIneligible()
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = false
end
playdate.timer.new(750, function()
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = true
end
end)
end
-- TODO? Start moving target fielders close sooner? -- TODO? Start moving target fielders close sooner?
---@param field Fielding ---@param field Fielding
---@param targetBase Base ---@param targetBase Base
---@param launchBall LaunchBall ---@param ball { launch: LaunchBall }
---@param throwFlyMs number ---@param throwFlyMs number
---@return ActionResult local function userThrowToCoroutine(field, targetBase, ball, 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)
closestFielder.target = targetBase closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return ActionResult.Succeeded return
end
end
end end
--- Buffer in a fielder throw action. --- Buffer in a fielder throw action.
---@param self table ---@param self table
---@param targetBase Base ---@param targetBase Base
---@param launchBall LaunchBall ---@param ball { launch: LaunchBall }
---@param throwFlyMs number ---@param throwFlyMs number
function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs) function Fielding:userThrowTo(targetBase, ball, 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, ball, throwFlyMs)
end) end)
end end
function Fielding:celebrate() function Fielding.celebrate()
FielderDanceAnimator:reset(C.DanceBounceMs) FielderDanceAnimator:reset(C.DanceBounceMs)
end end
---@param fielderSprites SpriteCollection -- luacheck: ignore
---@param ball Point3d
---@return boolean ballIsHeldByAFielder
function Fielding:drawFielders(fielderSprites, ball)
local ballIsHeld = false
local danceOffset = FielderDanceAnimator:currentValue()
for _, fielder in pairs(self.fielders) do
ballIsHeld = drawFielder(fielderSprites, ball, fielder.x, fielder.y + danceOffset) or ballIsHeld
end
return ballIsHeld
end
if not playdate or playdate.TEST_MODE then if not playdate or playdate.TEST_MODE then
return { Fielding, newFielder } return { Fielding, newFielder }
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,295 @@
tracking=1
space 3
! 2
" 5
# 9
$ 8
% 12
& 11
' 3
( 5
) 5
* 8
+ 8
, 3
- 6
. 2
/ 6
0 9
1 4
2 9
3 9
4 9
5 9
6 9
7 9
8 10
9 9
: 2
; 2
< 7
= 7
> 7
? 9
@ 11
A 10
B 9
C 9
D 9
E 8
F 8
G 9
H 9
I 2
J 8
K 10
L 9
M 12
N 9
O 9
P 9
Q 9
R 9
S 9
T 10
U 9
V 10
W 14
X 8
Y 8
Z 8
[ 3
\ 6
] 3
^ 6
_ 8
` 3
a 8
b 8
c 8
d 8
e 8
f 6
g 8
h 8
i 2
j 4
k 8
l 2
m 12
n 8
o 8
p 8
q 8
r 6
s 8
t 6
u 8
v 8
w 12
x 9
y 8
z 8
{ 6
| 2
} 6
~ 10
… 8
¥ 8
‼ 5
™ 8
© 11
® 11
。 16
、 16
ぁ 16
あ 16
ぃ 16
い 16
ぅ 16
う 16
ぇ 16
え 16
ぉ 16
お 16
か 16
が 16
き 16
ぎ 16
く 16
ぐ 16
け 16
げ 16
こ 16
ご 16
さ 16
ざ 16
し 16
じ 16
す 16
ず 16
せ 16
ぜ 16
そ 16
ぞ 16
た 16
だ 16
ち 16
ぢ 16
っ 16
つ 16
づ 16
て 16
で 16
と 16
ど 16
な 16
に 16
ぬ 16
ね 16
の 16
は 16
ば 16
ぱ 16
ひ 16
び 16
ぴ 16
ふ 16
ぶ 16
ぷ 16
へ 16
べ 16
ぺ 16
ほ 16
ぼ 16
ぽ 16
ま 16
み 16
む 16
め 16
も 16
ゃ 16
や 16
ゅ 16
ゆ 16
ょ 16
よ 16
ら 16
り 16
る 16
れ 16
ろ 16
ゎ 16
わ 16
ゐ 16
ゑ 16
を 16
ん 16
ゔ 16
ゕ 16
ゖ 16
゛ 1
゜ 0
ゝ 16
ゞ 16
ゟ 16
16
ァ 16
ア 16
ィ 16
イ 16
ゥ 16
ウ 16
ェ 16
エ 16
ォ 16
オ 16
カ 16
ガ 16
キ 16
ギ 16
ク 16
グ 16
ケ 16
ゲ 16
コ 16
ゴ 16
サ 16
ザ 16
シ 16
ジ 16
ス 16
ズ 16
セ 16
ゼ 16
ソ 16
ゾ 16
タ 16
ダ 16
チ 16
ヂ 16
ッ 16
ツ 16
ヅ 16
テ 16
デ 16
ト 16
ド 16
ナ 16
ニ 16
ヌ 16
ネ 16
16
ハ 16
バ 16
パ 16
ヒ 16
ビ 16
ピ 16
フ 16
ブ 16
プ 16
ヘ 16
ベ 16
ペ 16
ホ 16
ボ 16
ポ 16
マ 16
ミ 16
ム 16
メ 16
モ 16
ャ 16
ヤ 16
ュ 16
ユ 16
ョ 16
ヨ 16
ラ 16
リ 16
ル 16
レ 16
ロ 16
ヮ 16
ワ 16
ヰ 16
ヱ 16
ヲ 16
ン 16
ヴ 16
ヵ 16
ヶ 16
ヷ 16
ヸ 16
ヹ 16
ヺ 16
・ 16
ー 16
ヽ 16
ヾ 16
ヿ 16
「 16
」 16
円 16
<EFBFBD> 13

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -7,7 +7,8 @@ function getDrawOffset(ballX, ballY)
if ballY > C.Screen.H or ballX >= C.BallOffscreen then if ballY > C.Screen.H or ballX >= C.BallOffscreen then
return 0, 0 return 0, 0
end end
offsetY = math.max(0, -1 * ballY) -- Keep the ball approximately in the center, once it's past C.Center.y - 30
offsetY = math.max(0, (-1 * ballY) + C.Center.y - 30)
if ballX > 0 and ballX < C.Screen.W then if ballX > 0 and ballX < C.Screen.W then
offsetX = 0 offsetX = 0
@ -17,10 +18,11 @@ function getDrawOffset(ballX, ballY)
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W) offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
end end
return offsetX * 1.3, offsetY * 1.5 return offsetX * 1.3, offsetY
end end
-- selene: allow(unscoped_variables) ---@class Blipper
---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number)
blipper = {} blipper = {}
--- Build an object that simply "blips" between the given images at the given interval. --- Build an object that simply "blips" between the given images at the given interval.
@ -39,9 +41,3 @@ function blipper.new(msInterval, smiling, lowHat)
end, end,
} }
end end
--selene: allow(unscoped_variables)
HomeTeamBlipper = blipper.new(100, HomeTeamSprites.smiling, HomeTeamSprites.lowHat)
--selene: allow(unscoped_variables)
AwayTeamBlipper = blipper.new(100, AwayTeamSprites.smiling, AwayTeamSprites.lowHat)

BIN
src/images/game/BigBat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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

@ -0,0 +1,121 @@
---@class MainMenu
MainMenu = {
---@type { new: fun(settings: Settings): { update: fun(self) } }
next = nil,
}
local gfx = playdate.graphics
local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft")
--- Take control of playdate.update
--- Will replace playdate.update when the menu is done.
---@param next { new: fun(settings: Settings): { update: fun(self) } }
function MainMenu.start(next)
MenuMusic:play(0)
MainMenu.next = next
playdate.update = MainMenu.update
end
local inningCountSelection = 3
local function startGame()
local next = MainMenu.next.new({
finalInning = inningCountSelection,
homeTeamSprites = HomeTeamSprites,
awayTeamSprites = AwayTeamSprites,
})
playdate.resetElapsedTime()
transitionBetween(MainMenu, next)
MenuMusic:setPaused(true)
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
--luacheck: ignore
function MainMenu:update()
playdate.timer.updateTimers()
crankStartPos = crankStartPos or playdate.getCrankPosition()
gfx.clear()
if playdate.getCrankChange() ~= 0 then
local crankOffset = (crankStartPos - playdate.getCrankPosition()) % 360
currentLogo = arrayElementFromCrank(Logos, crankOffset).image
replaceAwayLogo(currentLogo)
end
if currentLogo then
currentLogo:drawScaled(20, C.Center.y + 40, 3)
end
if playdate.buttonJustPressed(playdate.kButtonA) then
startGame()
end
if playdate.buttonJustPressed(playdate.kButtonUp) then
inningCountSelection = inningCountSelection + 1
end
if playdate.buttonJustPressed(playdate.kButtonDown) then
inningCountSelection = inningCountSelection - 1
end
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

@ -8,9 +8,12 @@ import 'CoreLibs/timer.lua'
import 'CoreLibs/ui.lua' import 'CoreLibs/ui.lua'
-- stylua: ignore end -- stylua: ignore end
--- @alias Scene { update: fun(self: self) }
--- @alias EasingFunc fun(number, number, number, number): number --- @alias EasingFunc fun(number, number, number, number): number
--- @alias LaunchBall fun( --- @alias LaunchBall fun(
--- self: self,
--- destX: number, --- destX: number,
--- destY: number, --- destY: number,
--- easingFunc: EasingFunc, --- easingFunc: EasingFunc,
@ -23,9 +26,7 @@ import 'CoreLibs/ui.lua'
import 'utils.lua' import 'utils.lua'
import 'constants.lua' import 'constants.lua'
import 'assets.lua' import 'assets.lua'
import 'draw/player.lua' import 'main-menu.lua'
import 'draw/overlay.lua'
import 'draw/fielder.lua'
import 'action-queue.lua' import 'action-queue.lua'
import 'announcer.lua' import 'announcer.lua'
@ -35,190 +36,268 @@ import 'dbg.lua'
import 'fielding.lua' import 'fielding.lua'
import 'graphics.lua' import 'graphics.lua'
import 'npc.lua' import 'npc.lua'
import 'pitching.lua'
import 'draw/box-score.lua'
import 'draw/fielder.lua'
import 'draw/overlay.lua'
import 'draw/panner.lua'
import 'draw/player.lua'
import 'draw/throw-meter.lua'
import 'draw/transitions.lua'
-- stylua: ignore end -- stylua: ignore end
-- selene: allow(shadowing) -- TODO: Customizable field structure. E.g. stands and ads etc.
local gfx <const>, C <const> = playdate.graphics, C local gfx <const>, C <const> = playdate.graphics, C
local announcer = Announcer.new() ---@alias Team { benchPosition: XyPair }
local fielding = Fielding.new() ---@type table<TeamId, Team>
-- TODO: Find a way to get baserunning and npc instantiated closer to the top, here.
-- Currently difficult because they depend on nextHalfInning/each other.
------------------
-- GLOBAL STATE --
------------------
local deltaSeconds = 0
local ball = Ball.new(gfx.animator)
local batBase <const> = utils.xy(C.Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)
local batAngleDeg = C.CrankOffsetDeg
local catcherThrownBall = false
---@alias Team { score: number, benchPosition: XyPair }
---@type table<string, Team>
local teams <const> = { local teams <const> = {
home = { home = {
score = 0,
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y), benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
}, },
away = { away = {
score = 0,
benchPosition = utils.xy(-10, C.Center.y), benchPosition = utils.xy(-10, C.Center.y),
}, },
} }
local inning = 1 ---@alias TeamId 'home' | 'away'
local battingTeam = teams.away --- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
local offenseState = C.Offense.batting ---@class Settings
---@field finalInning number
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0 ---@field userTeam TeamId | nil
local secondsSinceLastRunnerMove = 0 ---@field awayTeamSprites SpriteCollection
local secondsSincePitchAllowed = 0 ---@field homeTeamSprites SpriteCollection
---@class MutableState
---@field deltaSeconds number
---@field ball Ball
---@field battingTeam TeamId
---@field pitchIsOver boolean
---@field didSwing boolean
---@field offenseState OffenseState
---@field inning number
---@field stats Statistics
---@field batBase XyPair
---@field batTip XyPair
---@field batAngleDeg number
-- 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 ---@field runnerBlipper Blipper
local battingTeamSprites = AwayTeamSprites ---@field battingTeamSprites SpriteCollection
local fieldingTeamSprites = HomeTeamSprites ---@field fieldingTeamSprites SpriteCollection
------------------------- ---@class Game
-- END OF GLOBAL STATE -- ---@field private settings Settings
------------------------- ---@field private announcer Announcer
---@field private fielding Fielding
---@field private baserunning Baserunning
---@field private npc Npc
---@field private homeTeamBlipper Blipper
---@field private awayTeamBlipper Blipper
---@field private panner Panner
---@field private state MutableState
Game = {}
local UserTeam <const> = teams.away ---@param settings Settings
---@param announcer Announcer | nil
---@param fielding Fielding | nil
---@param baserunning Baserunning | nil
---@param npc Npc | nil
---@param state MutableState | nil
---@return Game
function Game.new(settings, announcer, fielding, baserunning, npc, state)
announcer = announcer or Announcer.new()
fielding = fielding or Fielding.new()
settings.userTeam = "away"
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat)
local battingTeam = "away"
local runnerBlipper = battingTeam == "away" and awayTeamBlipper or homeTeamBlipper
local ball = Ball.new(gfx.animator)
---@type Pitch[] local o = setmetatable({
local Pitches <const> = { settings = settings,
-- Fastball announcer = announcer,
{ fielding = fielding,
x = gfx.animator.new(0, C.PitchStartX, C.PitchStartX, playdate.easingFunctions.linear), homeTeamBlipper = homeTeamBlipper,
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), awayTeamBlipper = awayTeamBlipper,
panner = Panner.new(ball),
state = state or {
batBase = utils.xy(C.Center.x - 34, 215),
batTip = utils.xy(0, 0),
batAngleDeg = C.CrankOffsetDeg,
deltaSeconds = 0,
ball = ball,
battingTeam = battingTeam,
offenseState = C.Offense.batting,
inning = 1,
pitchIsOver = true,
didSwing = false,
battingTeamSprites = settings.awayTeamSprites,
fieldingTeamSprites = settings.homeTeamSprites,
runnerBlipper = runnerBlipper,
stats = Statistics.new(),
}, },
-- Curve ball }, { __index = Game })
{
x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX + 20, C.PitchStartX, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
},
-- Slider
{
x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX - 20, C.PitchStartX, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
},
-- Wobbbleball
{
x = {
currentValue = function()
return C.PitchStartX + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStartY) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
},
}
o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning()
end)
o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)
o.fielding:resetFielderPositions(teams.home.benchPosition)
playdate.timer.new(2000, function()
o:returnToPitcher()
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
return o
end
---@param teamId TeamId
---@return TeamId
local function getOppositeTeamId(teamId)
if teamId == "home" then
return "away"
elseif teamId == "away" then
return "home"
else
error("Unknown TeamId: " .. (teamId or "nil"))
end
end
function Game:getFieldingTeam()
return teams[getOppositeTeamId(self.state.battingTeam)]
end
---@param side Side
---@return boolean userIsOnSide, boolean userIsOnOtherSide ---@return boolean userIsOnSide, boolean userIsOnOtherSide
local function userIsOn(side) function Game:userIsOn(side)
if UserTeam == nil then if self.settings.userTeam == nil then
-- Both teams are NPC-driven -- Both teams are NPC-driven
return false, false return false, false
end end
local ret local ret
if UserTeam == battingTeam then if self.settings.userTeam == self.state.battingTeam then
ret = side == C.Sides.offense ret = side == "offense"
else else
ret = side == C.Sides.defense ret = side == "defense"
end end
return ret, not ret return ret, not ret
end end
---@type LaunchBall
local function launchBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
throwMeter:reset()
ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
end
---@param pitchFlyTimeMs number | nil ---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil ---@param pitchTypeIndex number | nil
local function pitch(pitchFlyTimeMs, pitchTypeIndex) ---@param accuracy number The closer to 1.0, the better
Fielding.markIneligible(fielding.fielders.pitcher) function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy)
ball.heldBy = nil self.state.ball:markUncatchable()
catcherThrownBall = false self.state.ball.heldBy = nil
offenseState = C.Offense.batting self.state.pitchIsOver = false
self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex] local current = Pitches[pitchTypeIndex](accuracy, self.state.ball)
ball.xAnimator = current.x self.state.ball.xAnimator = current.x
ball.yAnimator = current.y or Pitches[1].y self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y
-- TODO: This would need to be sanely replaced in launchBall() etc. -- TODO: This would need to be sanely replaced in ball:launch() etc.
-- if current.z then -- if current.z then
-- ball.floatAnimator = current.z -- ball.floatAnimator = current.z
-- ball.floatAnimator:reset() -- ball.floatAnimator:reset()
-- end -- end
if pitchFlyTimeMs then if pitchFlyTimeMs then
ball.xAnimator:reset(pitchFlyTimeMs) self.state.ball.xAnimator:reset(pitchFlyTimeMs)
ball.yAnimator:reset(pitchFlyTimeMs) self.state.ball.yAnimator:reset(pitchFlyTimeMs)
else else
ball.xAnimator:reset() self.state.ball.xAnimator:reset()
ball.yAnimator:reset() self.state.ball.yAnimator:reset()
end end
secondsSincePitchAllowed = 0 pitchTracker.secondsSinceLastPitch = 0
end end
local function nextHalfInning() function Game:pitcherIsOnTheMound()
return utils.distanceBetweenPoints(self.fielding.fielders.pitcher, C.PitcherStartPos) < C.BaseHitbox
end
function Game:pitcherIsReady()
local pitcher = self.fielding.fielders.pitcher
return self:pitcherIsOnTheMound()
and (
self.state.ball.heldBy == pitcher
or utils.distanceBetweenPoints(pitcher, self.state.ball) < C.BallCatchHitbox
or utils.distanceBetweenPoints(self.state.ball, C.PitchStart) < 2
)
end
function Game:nextHalfInning()
pitchTracker:reset() pitchTracker:reset()
local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home local homeScore, awayScore = utils.totalScores(self.state.stats)
local gameOver = inning == 9 and teams.away.score ~= teams.home.score local isFinalInning = self.state.inning >= self.settings.finalInning
if not gameOver then local gameOver = isFinalInning and self.state.battingTeam == "home" and awayScore ~= homeScore
fielding:celebrate() gameOver = gameOver or self.state.battingTeam == "away" and isFinalInning and homeScore > awayScore
secondsSinceLastRunnerMove = -7 Fielding.celebrate()
fielding:benchTo(currentlyFieldingTeam.benchPosition)
announcer:say("SWITCHING SIDES...")
end
if gameOver then if gameOver then
announcer:say("AND THAT'S THE BALL GAME!") self.announcer:say("THAT'S THE BALL GAME!")
else playdate.timer.new(3000, function()
fielding:resetFielderPositions() transitionTo(BoxScore.new(self.state.stats))
if battingTeam == teams.home then end)
inning = inning + 1 return
end end
battingTeam = currentlyFieldingTeam
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...")
self.fielding:resetFielderPositions()
if self.state.battingTeam == "home" then
self.state.inning = self.state.inning + 1
self.state.stats:pushInning()
end
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
if battingTeam == teams.home then if self.state.battingTeam == "home" then
battingTeamSprites = HomeTeamSprites self.state.battingTeamSprites = self.settings.homeTeamSprites
runnerBlipper = HomeTeamBlipper self.state.runnerBlipper = self.homeTeamBlipper
fieldingTeamSprites = AwayTeamSprites self.state.fieldingTeamSprites = self.settings.awayTeamSprites
else else
battingTeamSprites = AwayTeamSprites self.state.battingTeamSprites = self.settings.awayTeamSprites
fieldingTeamSprites = HomeTeamSprites self.state.fieldingTeamSprites = self.settings.homeTeamSprites
runnerBlipper = AwayTeamBlipper self.state.runnerBlipper = self.awayTeamBlipper
end end
end) end)
end end
---@return TeamInningData
function Game:battingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][self.state.battingTeam]
end end
local baserunning = Baserunning.new(announcer, nextHalfInning) ---@return TeamInningData
local npc = Npc.new(baserunning.runners, fielding.fielders) function Game:fieldingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][getOppositeTeamId(self.state.battingTeam)]
end
---@param scoredRunCount number ---@param scoredRunCount number
local function score(scoredRunCount) function Game:score(scoredRunCount)
battingTeam.score = battingTeam.score + scoredRunCount -- TODO: end the game when it's the bottom of the ninth the home team is now in the lead.
announcer:say("SCORE!") -- outRunners/scoredRunners split
self:battingTeamCurrentInning().score = self:battingTeamCurrentInning().score + scoredRunCount
self.announcer:say("SCORE!")
end end
---@param throwFlyMs number ---@param throwFlyMs number
---@return boolean didThrow ---@return boolean didThrow
local function buttonControlledThrow(throwFlyMs, forbidThrowHome) function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third] targetBase = C.Bases[C.Third]
@ -232,62 +311,73 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
return false return false
end end
-- Power for this throw has already been determined self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
throwMeter:reset() self.baserunning.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running
fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
secondsSinceLastRunnerMove = 0
offenseState = C.Offense.running
return true return true
end end
local function nextBatter() function Game:nextBatter()
secondsSincePitchAllowed = -3 pitchTracker.secondsSinceLastPitch = -3
baserunning.batter = nil self.baserunning.batter = nil
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
pitchTracker:reset() pitchTracker:reset()
if not baserunning.batter then if not self.baserunning.batter then
baserunning:pushNewBatter() self.baserunning:pushNewBatter()
end end
end) end)
end end
local function walk() function Game:walk()
announcer:say("Walk!") self.announcer:say("Walk!")
-- TODO? Use baserunning:convertBatterToRunner() -- TODO? Use self.baserunning:convertBatterToRunner()
baserunning.batter.nextBase = C.Bases[C.First] self.baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home] self.baserunning.batter.prevBase = C.Bases[C.Home]
offenseState = C.Offense.walking self.state.offenseState = C.Offense.walking
baserunning.batter = nil self.baserunning.batter = nil
baserunning:updateForcedRunners() self.baserunning:updateForcedRunners()
nextBatter() self:nextBatter()
end end
local function strikeOut() function Game:strikeOut()
local outBatter = baserunning.batter local outBatter = self.baserunning.batter
baserunning.batter = nil self.baserunning.batter = nil
baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!") self.baserunning:outRunner(outBatter, "Strike out!")
nextBatter() self:nextBatter()
end end
local SwingBackDeg <const> = 30
local SwingForwardDeg <const> = 170
---@param batDeg number ---@param batDeg number
local function updateBatting(batDeg, batSpeed) function Game:updateBatting(batDeg, batSpeed)
if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then
self.state.didSwing = true
end
local batAngle = math.rad(batDeg) local batAngle = math.rad(batDeg)
-- TODO: animate bat-flip or something -- TODO: animate bat-flip or something
batBase.x = baserunning.batter and (baserunning.batter.x + C.BatterHandPos.x) or 0 self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0
batBase.y = baserunning.batter and (baserunning.batter.y + C.BatterHandPos.y) or 0 self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0
batTip.x = batBase.x + (C.BatLength * math.sin(batAngle)) self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
batTip.y = batBase.y + (C.BatLength * math.cos(batAngle)) self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
if if
batSpeed > 0 batSpeed > 0
and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H) and self.state.ball.y < 232
and ball.y < 232 and utils.pointDirectlyUnderLine(
self.state.ball.x,
self.state.ball.y,
self.state.batBase.x,
self.state.batBase.y,
self.state.batTip.x,
self.state.batTip.y,
C.Screen.H
)
then then
-- Hit! -- Hit!
BatCrackReverb:play() BatCrackReverb:play()
offenseState = C.Offense.running self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90) local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15) local mult = math.abs(batSpeed / 15)
@ -297,52 +387,86 @@ local function updateBatting(batDeg, batSpeed)
ballVelX = ballVelX * -1 ballVelX = ballVelX * -1
ballVelY = ballVelY * -1 ballVelY = ballVelY * -1
end end
local ballDestX = ball.x + (ballVelX * C.BattingPower) local ballDestX = self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = ball.y + (ballVelY * C.BattingPower) local ballDestY = self.state.ball.y + (ballVelY * C.BattingPower)
pitchTracker:reset() pitchTracker:reset()
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) local flyTimeMs = 2000
launchBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler)
-- TODO? A dramatic eye-level view on a home-run could be sick. -- TODO? A dramatic eye-level view on a home-run could be sick.
local battingTeamStats = self:battingTeamCurrentInning()
battingTeamStats.hits[#battingTeamStats.hits + 1] = utils.xy(ballDestX, ballDestY)
if utils.isFoulBall(ballDestX, ballDestY) then if utils.isFoulBall(ballDestX, ballDestY) then
announcer:say("Foul ball!") self.announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2) pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out -- TODO: Have a fielder chase for the fly-out
return return
end end
baserunning:convertBatterToRunner() if utils.pointIsSquarelyAboveLine(utils.xy(ballDestX, ballDestY), C.OutfieldWall) then
playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted
if utils.within(1, self.state.ball.x, ballDestX) and utils.within(1, self.state.ball.y, ballDestY) then
self.announcer:say("HOME RUN!")
self.state.offenseState = C.Offense.homeRun
-- Linger on the home-run ball for a moment, before panning to the bases.
playdate.timer.new(1000, function()
self.panner:panTo(utils.xy(0, 0), function()
return self:pitcherIsReady()
end)
end)
end
end)
end
fielding:haveSomeoneChase(ballDestX, ballDestY) local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
self.state.ball:launch(ballDestX, ballDestY, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler)
self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ballDestX, ballDestY)
end end
end end
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved ---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
local function updateNonBatterRunners(appliedSpeed, forcedOnly) function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
if runnersScored ~= 0 then if runnersScored ~= 0 then
score(runnersScored) self:score(runnersScored)
end end
return runnerMoved return runnersStillMoving, secondsSinceLastRunnerMove
end end
local function userPitch(throwFly) function Game:returnToPitcher()
local aButton = playdate.buttonIsPressed(playdate.kButtonA) self.fielding:resetFielderPositions()
local bButton = playdate.buttonIsPressed(playdate.kButtonB) actionQueue:upsert("returnToPitcher", 60 * 1000, function()
if not aButton and not bButton then while not self:pitcherIsOnTheMound() do
pitch(throwFly, 1) coroutine.yield()
elseif aButton and not bButton then end
pitch(throwFly, 2) if not self.baserunning.batter then
elseif not aButton and bButton then self.baserunning:pushNewBatter()
pitch(throwFly, 3) end
elseif aButton and bButton then self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
pitch(throwFly, 4) end)
end
---@param throwFly number
function Game:userPitch(throwFly, accuracy)
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
if not aPressed and not bPressed then
self:pitch(throwFly, 1, accuracy)
elseif aPressed and not bPressed then
self:pitch(throwFly, 2, accuracy)
elseif not aPressed and bPressed then
self:pitch(throwFly, 3, accuracy)
elseif aPressed and bPressed then
self:pitch(throwFly, 4, accuracy)
end end
end end
local function updateGameState() function Game:updateGameState()
deltaSeconds = playdate.getElapsedTime() or 0 self.state.deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime() playdate.resetElapsedTime()
local crankChange = playdate.getCrankChange() --[[@as number]] local crankChange = playdate.getCrankChange() --[[@as number]]
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower) local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
@ -350,151 +474,183 @@ local function updateGameState()
crankLimited = crankLimited * -1 crankLimited = crankLimited * -1
end end
ball:updatePosition() self.state.ball:updatePosition()
local userOnOffense, userOnDefense = userIsOn(C.Sides.offense) local userOnOffense, userOnDefense = self:userIsOn("offense")
if userOnDefense then local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
throwMeter:applyCharge(deltaSeconds, crankLimited)
if fielderHoldingBall then
local outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
if not userOnDefense and self.state.offenseState == C.Offense.running then
self.npc:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
end
end end
if offenseState == C.Offense.batting then if self.state.offenseState == C.Offense.batting then
if ball.y < C.StrikeZoneStartY then pitchTracker:recordIfPassed(self.state.ball)
pitchTracker.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then
pitchTracker.recordedPitchX = ball.x
end
local pitcher = fielding.fielders.pitcher local pitcher = self.fielding.fielders.pitcher
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
end end
if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
local outcome = pitchTracker:updatePitchCounts() local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
if outcome == PitchOutcomes.StrikeOut then if outcome == PitchOutcomes.StrikeOut then
strikeOut() self:strikeOut()
elseif outcome == PitchOutcomes.Walk then elseif outcome == PitchOutcomes.Walk then
walk() self:walk()
end end
-- Catcher has the ball. Throw it back to the pitcher self:returnToPitcher()
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) self.state.pitchIsOver = true
catcherThrownBall = true self.state.didSwing = false
end end
local batSpeed local batSpeed
if userOnOffense then if userOnOffense then
batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
batSpeed = crankLimited batSpeed = crankLimited
else else
batAngleDeg = npc:updateBatAngle(ball, catcherThrownBall, deltaSeconds) self.state.batAngleDeg =
batSpeed = npc:batSpeed() * deltaSeconds self.npc:updateBatAngle(self.state.ball, self.state.pitchIsOver, self.state.deltaSeconds)
batSpeed = self.npc:batSpeed() * self.state.deltaSeconds
end end
updateBatting(batAngleDeg, batSpeed) self:updateBatting(self.state.batAngleDeg, batSpeed)
-- Walk batter to the plate -- Walk batter to the plate
-- TODO: Ensure batter can't be nil, here self.baserunning:updateRunner(self.baserunning.batter, nil, userOnOffense and crankLimited or 0, self.state.deltaSeconds)
baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds)
if secondsSincePitchAllowed > C.PitchAfterSeconds then if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
if userOnDefense then if userOnDefense then
local throwFly = throwMeter:readThrow() local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange)
if throwFly and not buttonControlledThrow(throwFly, true) then if powerRatio then
userPitch(throwFly) local throwFly = C.PitchFlyMs / powerRatio
if throwFly and not self:buttonControlledThrow(throwFly, true) then
self:userPitch(throwFly, accuracy)
end
end end
else else
pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches)) self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), math.random(#Pitches))
end end
end end
elseif offenseState == C.Offense.running then elseif self.state.offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited local appliedSpeed = userOnOffense and crankLimited
or function(runner) or function(runner)
return npc:runningSpeed(runner, ball) return self.npc:runningSpeed(runner, self.state.ball)
end end
if updateNonBatterRunners(appliedSpeed) then local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false)
secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher -- End of play. Throw the ball back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) self.state.offenseState = C.Offense.batting
fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly. self:returnToPitcher()
fielding:resetFielderPositions()
offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter()
if not baserunning.batter then
baserunning:pushNewBatter()
end end
end
end
elseif offenseState == C.Offense.walking then
if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
offenseState = C.Offense.batting
end
end
local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds)
if userOnDefense then if userOnDefense then
local throwFly = throwMeter:readThrow() local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly then if throwFly then
buttonControlledThrow(throwFly) self:buttonControlledThrow(throwFly)
end end
end end
if fielderHoldingBall then end
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) elseif self.state.offenseState == C.Offense.walking then
if not userOnDefense and offenseState == C.Offense.running then if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall) self.state.offenseState = C.Offense.batting
end
elseif self.state.offenseState == C.Offense.homeRun then
self:updateNonBatterRunners(C.WalkedRunnerSpeed * 2, false)
if #self.baserunning.runners == 0 then
-- Give the player a moment to enjoy their home run.
playdate.timer.new(1500, function()
self:returnToPitcher()
actionQueue:upsert("waitForPitcherToHaveBall", 10000, function()
while not self:pitcherIsReady() do
coroutine.yield()
end
self.state.offenseState = C.Offense.batting
end)
end)
end end
end end
baserunning:walkAwayOutRunners(deltaSeconds) self.baserunning:walkAwayOutRunners(self.state.deltaSeconds)
actionQueue:runWaiting(deltaSeconds) actionQueue:runWaiting(self.state.deltaSeconds)
end end
-- TODO: Swappable update() for main menu, etc. function Game:update()
function playdate.update()
playdate.timer.updateTimers() playdate.timer.updateTimers()
gfx.animation.blinker.updateAll() gfx.animation.blinker.updateAll()
updateGameState() self:updateGameState()
local ball = self.state.ball
gfx.clear() gfx.clear()
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
local offsetX, offsetY = getDrawOffset(ball.x, ball.y) local offsetX, offsetY = self.panner:get(self.state.deltaSeconds)
gfx.setDrawOffset(offsetX, offsetY) gfx.setDrawOffset(offsetX, offsetY)
GrassBackground:draw(-400, -240) GrassBackground:draw(-400, -720)
local ballIsHeld = fielding:drawFielders(fieldingTeamSprites, ball) ---@type { y: number, drawAction: fun() }[]
local characterDraws = {}
if offenseState == C.Offense.batting then function addDraw(y, drawAction)
gfx.setLineWidth(5) characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
end end
local playerHeightOffset = 10 local danceOffset = FielderDanceAnimator:currentValue()
-- TODO? Scale sprites down as y increases ---@type Fielder | nil
for _, runner in pairs(baserunning.runners) do local ballHeldBy
if runner == baserunning.batter then for _, fielder in pairs(self.fielding.fielders) do
if batAngleDeg > 50 and batAngleDeg < 200 then addDraw(fielder.y + danceOffset, function()
battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset) local ballHeldByThisFielder =
drawFielder(self.state.fieldingTeamSprites, self.state.ball, fielder.x, fielder.y + danceOffset)
if ballHeldByThisFielder then
ballHeldBy = fielder
end
end)
end
local playerHeightOffset = 20
for _, runner in pairs(self.baserunning.runners) do
addDraw(runner.y, function()
if runner == self.baserunning.batter then
if self.state.batAngleDeg > 50 and self.state.batAngleDeg < 200 then
self.state.battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
else else
battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset) self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
end end
else else
-- TODO? Change blip speed depending on runner speed? -- TODO? Change blip speed depending on runner speed?
runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset) self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
end end
end)
end end
for _, runner in pairs(baserunning.outRunners) do table.sort(characterDraws, function(a, b)
battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset) return a.y < b.y
end)
for _, character in pairs(characterDraws) do
character.drawAction()
end end
if not ballIsHeld then if self.state.offenseState == C.Offense.batting then
gfx.setLineWidth(5)
gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y)
end
for _, runner in pairs(self.baserunning.outRunners) do
self.state.battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset)
end
for _, runner in pairs(self.baserunning.scoredRunners) do
self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
end
throwMeter:drawNearFielder(ballHeldBy)
if not ballHeldBy then
gfx.setLineWidth(2) gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite) gfx.setColor(gfx.kColorWhite)
@ -506,31 +662,30 @@ function playdate.update()
gfx.setDrawOffset(0, 0) gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(baserunning.runners, fielding.fielders) drawMinimap(self.baserunning.runners, self.fielding.fielders)
end end
drawScoreboard(0, C.Screen.H * 0.77, teams, baserunning.outs, battingTeam, inning) local homeScore, awayScore = utils.totalScores(self.state.stats)
drawScoreboard(
0,
C.Screen.H * 0.77,
homeScore,
awayScore,
self.baserunning.outs,
self.state.battingTeam,
self.state.inning
)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes) drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
announcer:draw(C.Center.x, 10) self.announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then if playdate.isCrankDocked() then
-- luacheck: ignore
playdate.ui.crankIndicator:draw() playdate.ui.crankIndicator:draw()
end end
end end
local function init()
playdate.display.setRefreshRate(50) playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite) gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
fielding:resetFielderPositions(teams.home.benchPosition)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function() MainMenu.start(Game)
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
end
init()

BIN
src/music/MenuMusic.wav Normal file

Binary file not shown.

View File

@ -18,16 +18,18 @@ function Npc.new(runners, fielders)
}, { __index = Npc }) }, { __index = Npc })
end end
-- TODO: FAR more nuanced NPC batting.
---@param ball XyPair ---@param ball XyPair
---@param catcherThrownBall boolean ---@param pitchIsOver boolean
---@param deltaSec number ---@param deltaSec number
---@return number ---@return number
function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec) -- luacheck: no unused
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then function Npc:updateBatAngle(ball, pitchIsOver, deltaSec)
if not pitchIsOver and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed) npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
else else
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
npcBatDeg = 200 npcBatDeg = 230
end end
return npcBatDeg return npcBatDeg
end end
@ -119,35 +121,31 @@ end
---@param fielders Fielder[] ---@param fielders Fielder[]
---@param fielder Fielder ---@param fielder Fielder
---@param runners Runner[] ---@param runners Runner[]
---@param launchBall LaunchBall ---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall) local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners) local targetX, targetY = getNextOutTarget(runners)
if targetX ~= nil and targetY ~= nil then if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate) local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
return grabCandidate.catchEligible
end)
nearestFielder.target = utils.xy(targetX, targetY) nearestFielder.target = utils.xy(targetX, targetY)
if nearestFielder == fielder then if nearestFielder == fielder then
ball.heldBy = fielder ball.heldBy = fielder
else else
launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true) ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
Fielding.markIneligible(nearestFielder)
end end
end end
end end
---@param fielder Fielder ---@param fielder Fielder
---@param outedSomeRunner boolean ---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil } ---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
---@param launchBall LaunchBall function Npc:fielderAction(fielder, outedSomeRunner, ball)
function Npc:fielderAction(fielder, outedSomeRunner, ball, launchBall)
if outedSomeRunner then if outedSomeRunner then
-- Delay a little before the next play -- Delay a little before the next play
playdate.timer.new(750, function() playdate.timer.new(750, function()
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall) tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end) end)
else else
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall) tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end end
end end

183
src/pitching.lua Normal file
View File

@ -0,0 +1,183 @@
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch fun(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
---@type pd_graphics_lib
local gfx <const> = playdate.graphics
local StrikeZoneWidth <const> = C.StrikeZoneEndX - C.StrikeZoneStartX
-- TODO? Also degrade speed
function getPitchMissBy(accuracy)
accuracy = accuracy or 1.0
local missBy = (1 - accuracy) * StrikeZoneWidth * 3
if math.random() > 0.5 then
missBy = missBy * -1
end
return missBy
end
---@type Pitch[]
Pitches = {
-- Fastball
function(accuracy)
return {
x = gfx.animator.new(0, C.PitchStart.x, getPitchMissBy(accuracy) + C.PitchStart.x, playdate.easingFunctions.linear),
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Curve ball
function(accuracy)
return {
x = gfx.animator.new(C.PitchFlyMs, getPitchMissBy(accuracy) + C.PitchStart.x + 20, C.PitchStart.x, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Slider
function(accuracy)
return {
x = gfx.animator.new(C.PitchFlyMs, getPitchMissBy(accuracy) + C.PitchStart.x - 20, C.PitchStart.x, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Wobbbleball
function(accuracy, ball)
return {
x = {
currentValue = function()
return getPitchMissBy(accuracy) + C.PitchStart.x + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStart.y) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
}
PitchOutcomes = {
StrikeOut = "StrikeOut",
Walk = "Walk",
}
pitchTracker = {
--- Position of the pitch, or nil, if one has not been recorded.
---@type number | nil
recordedPitchX = nil,
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
secondsSinceLastPitch = 0,
strikes = 0,
balls = 0,
}
function pitchTracker:reset()
self.strikes = 0
self.balls = 0
end
function pitchTracker:recordIfPassed(ball)
if ball.y < C.StrikeZoneStartY then
self.recordedPitchX = nil
elseif not self.recordedPitchX then
self.recordedPitchX = ball.x
end
end
---@param didSwing boolean
---@param fieldingTeamInningData TeamInningData
function pitchTracker:updatePitchCounts(didSwing, fieldingTeamInningData)
if not self.recordedPitchX then
return
end
local currentPitchingStats = fieldingTeamInningData.pitching
if didSwing or self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
self.strikes = self.strikes + 1
currentPitchingStats.strikes = currentPitchingStats.strikes + 1
if self.strikes >= 3 then
self:reset()
return PitchOutcomes.StrikeOut
end
else
self.balls = self.balls + 1
currentPitchingStats.balls = currentPitchingStats.balls + 1
if self.balls >= 4 then
self:reset()
return PitchOutcomes.Walk
end
end
end
-----------------
-- Throw Meter --
-----------------
throwMeter = {
MinCharge = 25,
value = 0,
idealPower = 50,
lastReadThrow = nil,
--- Used at draw-time only.
---@type Fielder | nil
lastThrower = nil,
}
function throwMeter:reset()
self.value = 0
end
local crankQueue = {}
--- Returns nil when a throw is NOT requested.
---@param chargeAmount number
---@return number | nil powerRatio, number | nil accuracy, boolean isPerfect
function throwMeter:readThrow(chargeAmount)
local ret = self:applyCharge(chargeAmount)
if ret then
local ratio = ret / self.idealPower
local accuracy
if ratio >= 1 then
accuracy = 1 / ratio / 2
else
accuracy = 1 -- Accuracy is perfect on slow throws
end
return ratio * 1.5, accuracy, math.abs(ratio - 1) < 0.05
end
return nil, nil, false
end
--- If (within approx. a third of a second) the crank has moved more than 45 degrees, call that a throw.
---@param chargeAmount number
function throwMeter:applyCharge(chargeAmount)
if chargeAmount == 0 then
return
end
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
local removedOne = false
while #crankQueue ~= 0 and (currentTimeMs - crankQueue[1].time) > 0.33 do
table.remove(crankQueue, 1)
removedOne = true -- At least 1/3 second has passed
end
crankQueue[#crankQueue + 1] = { time = currentTimeMs, chargeAmount = math.abs(chargeAmount) }
if not removedOne then
return nil
end
local currentCharge = 0
for _, v in ipairs(crankQueue) do
currentCharge = currentCharge + v.chargeAmount
end
if currentCharge > throwMeter.MinCharge then
self.lastReadThrow = currentCharge
print(tostring(currentCharge) .. " from " .. #crankQueue)
crankQueue = {}
return currentCharge
else
return nil
end
end

View File

@ -11,7 +11,7 @@ function buildBaserunning()
return baserunning, thirdOutCallbackData return baserunning, thirdOutCallbackData
end end
---@alias BaseIndexOrXyPair (integer | XyPair) ---@alias BaseIndexOrXyPair (number | XyPair)
--- NOTE: in addition to the given runners, there is implicitly a batter running from first. --- NOTE: in addition to the given runners, there is implicitly a batter running from first.
---@param runnerLocations BaseIndexOrXyPair[] ---@param runnerLocations BaseIndexOrXyPair[]
@ -44,7 +44,7 @@ end
---@param expected boolean ---@param expected boolean
---@param fielderWithBallAt XyPair ---@param fielderWithBallAt XyPair
---@param when integer[][] ---@param when number[][]
function assertRunnerOutCondition(expected, when, fielderWithBallAt) function assertRunnerOutCondition(expected, when, fielderWithBallAt)
local msg = expected and "out" or "safe" local msg = expected and "out" or "safe"
for _, runnersOn in ipairs(when) do for _, runnersOn in ipairs(when) do

View File

@ -1,4 +1,4 @@
-- selene: allow(unscoped_variables) -- luacheck no new globals
utils = {} utils = {}
--- @alias XyPair { --- @alias XyPair {
@ -70,17 +70,28 @@ end
---@param mover { x: number, y: number } ---@param mover { x: number, y: number }
---@param speed number ---@param speed number
---@param target { x: number, y: number } ---@param target { x: number, y: number }
---@return boolean ---@param tau number | nil
function utils.moveAtSpeed(mover, speed, target) ---@return boolean isStillMoving
function utils.moveAtSpeed(mover, speed, target, tau)
local x, y, distance = utils.normalizeVector(mover.x, mover.y, target.x, target.y) local x, y, distance = utils.normalizeVector(mover.x, mover.y, target.x, target.y)
if distance > 1 then if distance == 0 then
return false
end
if distance > (tau or 1) then
mover.x = mover.x - (x * speed) mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed) mover.y = mover.y - (y * speed)
else
mover.x = target.x
mover.y = target.y
end
return true return true
end end
return false function utils.within(within, n1, n2)
return math.abs(n1 - n2) < within
end end
---@generic T ---@generic T
@ -164,6 +175,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)
@ -216,76 +243,19 @@ function utils.getNearestOf(array, x, y, extraCondition)
return nearest, nearestDistance return nearest, nearestDistance
end end
-- selene: allow(unscoped_variables) ---@param stats Statistics
PitchOutcomes = { ---@return number homeScore, number awayScore
StrikeOut = {}, function utils.totalScores(stats)
Walk = {}, local homeScore = 0
} local awayScore = 0
for _, inning in pairs(stats.innings) do
-- selene: allow(unscoped_variables) homeScore = homeScore + inning.home.score
pitchTracker = { awayScore = awayScore + inning.away.score
--- Position of the pitch, or nil, if one has not been recorded.
---@type number | nil
recordedPitchX = nil,
strikes = 0,
balls = 0,
reset = function(self)
self.strikes = 0
self.balls = 0
end,
updatePitchCounts = function(self)
if not self.recordedPitchX then
return
end end
if self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then return homeScore, awayScore
self.strikes = self.strikes + 1
if self.strikes >= 3 then
self:reset()
return PitchOutcomes.StrikeOut
end
else
self.balls = self.balls + 1
if self.balls >= 4 then
self:reset()
return PitchOutcomes.Walk
end
end
end,
}
-----------------
-- Throw Meter --
-----------------
-- selene: allow(unscoped_variables)
throwMeter = {
value = 0,
}
function throwMeter:reset()
self.value = 0
end
---@return number | nil flyTimeMs Returns nil when a throw is NOT requested.
function throwMeter:readThrow()
if self.value > C.ThrowMeterMax then
return (C.PitchFlyMs / (self.value / C.ThrowMeterMax))
end
return nil
end
--- Applies the given charge, but drains some meter for how much time has passed
---@param delta number
---@param chargeAmount number
function throwMeter:applyCharge(delta, chargeAmount)
self.value = math.max(0, self.value - (delta * C.ThrowMeterDrainPerSec))
self.value = self.value + math.abs(chargeAmount * C.UserThrowPower)
end end
if not playdate then if not playdate then
return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes, throwMeter = throwMeter } return utils
end end