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:
pdc --skip-unknown src BatterUp.pdx
@ -8,7 +6,7 @@ assets:
check: assets
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
(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 = {
---@type ({ action: Action, expireTimeMs: number })[]
---@type table<any, { coroutine: thread, expireTimeMs: number }>
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.
--- They will continue to be executed until they return Succeeded or Failed instead of NeedsMoreTime.
---
--- Replaces any existing action with the given name.
--- Replaces any existing action with the given id.
--- If the initial call of action() doesn't return NeedsMoreTime, this function will not bother adding it to the queue.
---@param name string
---@param id any
---@param maxTimeMs number
---@param action Action
function actionQueue:upsert(name, maxTimeMs, action)
if action(0) ~= ActionResult.NeedsMoreTime then
return
function actionQueue:upsert(id, maxTimeMs, action)
if self.queue[id] then
close(self.queue[id].coroutine)
end
self.queue[name] = {
action = action,
self.queue[id] = {
coroutine = coroutine.create(action),
expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(),
}
end
@ -39,10 +29,16 @@ end
--- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired.
function actionQueue:runWaiting(deltaSeconds)
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
for name, actionObject in pairs(self.queue) do
local result = actionObject.action(deltaSeconds)
if result ~= ActionResult.NeedsMoreTime or currentTimeMs > actionObject.expireTimeMs then
self.queue[name] = nil
for id, actionObject in pairs(self.queue) do
coroutine.resume(actionObject.coroutine, deltaSeconds)
if currentTimeMs > actionObject.expireTimeMs then
close(actionObject.coroutine)
end
if coroutine.status(actionObject.coroutine) == "dead" then
self.queue[id] = nil
end
end
end

View File

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

View File

@ -1,86 +1,67 @@
-- GENERATED FILE - DO NOT EDIT
-- Instead, edit the source file directly: assets.lua2p.
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
Glove = playdate.graphics.image.new("images/game/Glove.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
BigBat = playdate.graphics.image.new("images/game/BigBat.png")
-- luacheck: ignore
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
GameLogo = playdate.graphics.image.new("images/game/GameLogo.png")
-- luacheck: ignore
Hat = playdate.graphics.image.new("images/game/Hat.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
DarkPlayerBase = playdate.graphics.image.new("images/game/DarkPlayerBase.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
MenuImage = playdate.graphics.image.new("images/game/MenuImage.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
PlayerSmile = playdate.graphics.image.new("images/game/PlayerSmile.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
Minimap = playdate.graphics.image.new("images/game/Minimap.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
GrassBackgroundSmall = playdate.graphics.image.new("images/game/GrassBackgroundSmall.png")
-- luacheck: ignore
LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
-- luacheck: ignore
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 = {
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Cats = playdate.graphics.image.new("images/game/logos/Cats.png"),
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Hearts = playdate.graphics.image.new("images/game/logos/Hearts.png"),
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Checkmarks = playdate.graphics.image.new("images/game/logos/Checkmarks.png"),
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Smiles = playdate.graphics.image.new("images/game/logos/Smiles.png"),
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
FingerGuns = playdate.graphics.image.new("images/game/logos/FingerGuns.png"),
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
Base = playdate.graphics.image.new("images/game/logos/Base.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"),
{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
-- luacheck: ignore
{ name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") },
-- luacheck: ignore
{ name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") },
-- luacheck: ignore
{ name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") },
-- luacheck: ignore
{ name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") },
-- luacheck: ignore
{ name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") },
-- luacheck: ignore
{ name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") },
-- luacheck: ignore
{ name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") },
-- luacheck: ignore
{ name = "Turds", image = 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"
handle = handle ~= nil and handle or function(varName, value)
return varName .. ' = ' .. value
end
--Open directory look for files, save data in p. By giving '-type f' as parameter, it returns all files.
local p = io.popen('find src/' .. dir .. ' -type f -maxdepth 1')
local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f')
local assetCode = ""
--Loop through all files
@ -9,9 +12,8 @@
if file:find(extension) then
local varName = file:gsub(".*/(.*)." .. extension, "%1")
file = file:gsub("src/", "")
assetCode = assetCode .. '--selene: allow(unused_variable)\n'
assetCode = assetCode .. '--selene: allow(unscoped_variables)\n'
assetCode = assetCode .. varName .. ' = ' .. newFunc .. '("' .. file .. '")' .. sep
assetCode = assetCode .. '-- luacheck: ignore\n'
assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
end
end
return assetCode
@ -24,8 +26,10 @@ end)!!(generatedFileWarning())
!!(dirLookup('images/game', 'png', 'playdate.graphics.image.new'))
!!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new'))
!!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new'))
--selene: allow(unused_variable)
--selene: allow(unscoped_variables)
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
---@field x number
---@field y number
---@field z number
---@field size number
---@field heldBy Fielder | nil
---@field catchable boolean
---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator
---@field floatAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib
---@field private flyTimeMs number
Ball = {}
---@param animatorLib pd_animator_lib
@ -21,6 +20,7 @@ function Ball.new(animatorLib)
x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]],
z = 0,
catchable = true,
size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]],
@ -50,6 +50,13 @@ function Ball:updatePosition()
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.
---@param destX number
---@param destY number
@ -58,18 +65,19 @@ end
---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
throwMeter:reset()
self.heldBy = nil
-- Prevent silly insta-catches
self:markUncatchable()
if not flyTimeMs then
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
end
self.flyTimeMs = flyTimeMs
if customBallScaler then
self.sizeAnimator = customBallScaler
else
-- TODO? Scale based on distance?
self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
end
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)

View File

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

View File

@ -1,4 +1,3 @@
-- selene: allow(unscoped_variables)
C = {}
C.Screen = {
@ -31,7 +30,7 @@ C.FieldHeight = C.Bases[C.Home].y - C.Bases[C.Second].y
-- Pseudo-base for batter to target
C.RightHandedBattersBox = {
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>
@ -71,8 +70,8 @@ C.BallOffscreen = 999
C.PitchAfterSeconds = 6
C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050
C.PitchStartX = 195
C.PitchStartY, C.PitchEndY = 105, 240
C.PitchStart = utils.xy(195, 105)
C.PitchEndY = 240
C.DefaultLaunchPower = 4
@ -92,33 +91,23 @@ C.SmallestBallRadius = 6
C.BatLength = 35
-- TODO: enums implemented this way are probably going to be difficult to serialize!
---@alias OffenseState table
---@alias OffenseState "batting" | "running" | "walking" | "homeRun"
--- An enum for what state the offense is in
---@type table<string, OffenseState>
C.Offense = {
batting = {},
running = {},
walking = {},
batting = "batting",
running = "running",
walking = "walking",
homeRun = "homeRun",
}
---@alias Side table
--- An enum for which side (offense or defense) a team is on.
---@type table<string, Side>
C.Sides = {
offense = {},
defense = {},
}
---@alias Side "offense" | "defense"
C.PitcherStartPos = {
x = C.Screen.W * 0.48,
y = C.Screen.H * 0.40,
}
C.ThrowMeterMax = 10
C.ThrowMeterDrainPerSec = 150
--- Controls how hard the ball can be hit, and
--- how fast the ball can be thrown.
C.CrankPower = 10
@ -132,6 +121,24 @@ C.WalkedRunnerSpeed = 10
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
return C
end

View File

@ -1,7 +1,5 @@
-- selene: allow(unscoped_variables)
dbg = {}
-- selene: allow(unused_variable)
function dbg.label(value, name)
if type(value) == "table" then
print(name .. ":")
@ -19,7 +17,6 @@ function dbg.label(value, name)
end
-- 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)
br:pushNewBatter()
br:pushNewBatter()
@ -38,6 +35,76 @@ function dbg.loadTheBases(br)
br.runners[4].nextBase = C.Bases[C.Home]
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
return dbg
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 fielderY number
---@return boolean isHoldingBall
local function drawFielderGlove(ball, fielderX, fielderY)
local function drawFielderGlove(ball, fielderX, fielderY, flip)
local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
local shoulderX, shoulderY = fielderX + 10, fielderY - 5
if distanceFromBall > 20 then
Glove:draw(shoulderX, shoulderY)
Glove:draw(shoulderX, shoulderY, flip)
return false
else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY)
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
return true
end
end
@ -22,7 +22,7 @@ end
---@param x number
---@param y number
---@return boolean isHoldingBall
function drawFielder(playerSprites, ball, x, y)
playerSprites.smiling:draw(x, y - 20)
function drawFielder(playerSprites, ball, x, y, flip)
playerSprites.smiling:draw(x, y - 20, flip)
return drawFielderGlove(ball, x, y)
end

View File

@ -83,11 +83,10 @@ local ScoreboardHeight <const> = 55
local Indicator = "> "
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
---@param teams any
---@param battingTeam any
---@return string, number, string, number
function getIndicators(teams, battingTeam)
if teams.home == battingTeam then
function getIndicators(battingTeam)
if battingTeam == "home" then
return Indicator, 0, "", IndicatorWidth
end
return "", IndicatorWidth, Indicator, 0
@ -101,11 +100,11 @@ local stats = {
battingTeam = nil,
}
function drawScoreboardImpl(x, y, teams)
function drawScoreboardImpl(x, y)
local homeScore = stats.homeScore
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 awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
@ -146,17 +145,17 @@ end
local newStats = stats
function drawScoreboard(x, y, teams, outs, battingTeam, inning)
function drawScoreboard(x, y, homeScore, awayScore, outs, battingTeam, inning)
if
newStats.homeScore ~= teams.home.score
or newStats.awayScore ~= teams.away.score
newStats.homeScore ~= homeScore
or newStats.awayScore ~= awayScore
or newStats.outs ~= outs
or newStats.inning ~= inning
or newStats.battingTeam ~= battingTeam
then
newStats = {
homeScore = teams.home.score,
awayScore = teams.away.score,
homeScore = homeScore,
awayScore = awayScore,
outs = outs,
inning = inning,
battingTeam = battingTeam,
@ -165,5 +164,5 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning)
stats = newStats
end)
end
drawScoreboardImpl(x, y, teams)
drawScoreboardImpl(x, y)
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)
end
--- TODO: Custom names on jerseys?
---@return SpriteCollection
---@param base pd_image
---@param isDark boolean
@ -52,7 +53,20 @@ function buildCollection(base, back, logo, isDark)
end
--selene: allow(unscoped_variables)
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, Logos.Base, true)
---@type SpriteCollection
AwayTeamSprites = nil
--selene: allow(unscoped_variables)
HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, Logos.Frown, false)
---@type SpriteCollection
HomeTeamSprites = nil
function replaceAwayLogo(logo)
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, logo, true)
end
function replaceHomeLogo(logo)
HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, logo, false)
end
replaceAwayLogo(Logos[1].image)
replaceHomeLogo(Logos[2].image)

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 {
--- @field catchEligible boolean
--- @field x number
--- @field y number
--- @field target XyPair | nil
--- @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
---@field fielders table<string, Fielder>
---@field fielders Fielders
---@field fielderHoldingBall Fielder | nil
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
---@param name string
@ -23,7 +30,6 @@ local function newFielder(name, speed)
return {
name = name,
speed = speed * C.FielderRunMult,
catchEligible = true,
}
end
@ -79,45 +85,54 @@ end
---@param deltaSeconds number
---@param fielder Fielder
---@param ballPos XyPair
---@return boolean inCatchingRange
local function updateFielderPosition(deltaSeconds, fielder, ballPos)
---@param ball Ball
---@return boolean canCatch
local function updateFielderPosition(deltaSeconds, fielder, ball)
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
end
end
return utils.distanceBetweenPoints(fielder, ballPos) < C.BallCatchHitbox
return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox
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.
--- Other fielders should attempt to cover their bases
---@param self table
---@param ballDestX number
---@param ballDestY number
function Fielding:haveSomeoneChase(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
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
return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher
-- Skip the pitcher for 2B - they're considered closer than second or shortstop.
if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then
return false
end
return fielder ~= chasingFielder
end)
nearest.target = base
end
end
--- **Also updates `ball.heldby`**
---@param ball Ball
---@param deltaSeconds number
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderHoldingBall = nil
local fielderHoldingBall
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
ball.heldBy = fielder -- How much havoc will this wreak?
end
@ -129,73 +144,45 @@ function Fielding:updateFielderPositions(ball, deltaSeconds)
return fielderHoldingBall
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?
---@param field Fielding
---@param targetBase Base
---@param launchBall LaunchBall
---@param ball { launch: LaunchBall }
---@param throwFlyMs number
---@return ActionResult
local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs)
if field.fielderHoldingBall == nil then
return ActionResult.NeedsMoreTime
end
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end)
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
while true do
if field.fielderHoldingBall == nil then
coroutine.yield()
else
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end)
closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return ActionResult.Succeeded
closestFielder.target = targetBase
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
return
end
end
end
--- Buffer in a fielder throw action.
---@param self table
---@param targetBase Base
---@param launchBall LaunchBall
---@param ball { launch: LaunchBall }
---@param throwFlyMs number
function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
function Fielding:userThrowTo(targetBase, ball, throwFlyMs)
local maxTryTimeMs = 5000
actionQueue:upsert("userThrowTo", maxTryTimeMs, function()
return userThrowToImpl(self, targetBase, launchBall, throwFlyMs)
userThrowToCoroutine(self, targetBase, ball, throwFlyMs)
end)
end
function Fielding:celebrate()
function Fielding.celebrate()
FielderDanceAnimator:reset(C.DanceBounceMs)
end
---@param fielderSprites SpriteCollection
---@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
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return { Fielding, newFielder }
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
return 0, 0
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
offsetX = 0
@ -17,10 +18,11 @@ function getDrawOffset(ballX, ballY)
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
end
return offsetX * 1.3, offsetY * 1.5
return offsetX * 1.3, offsetY
end
-- selene: allow(unscoped_variables)
---@class Blipper
---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number)
blipper = {}
--- 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
--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'
-- stylua: ignore end
--- @alias Scene { update: fun(self: self) }
--- @alias EasingFunc fun(number, number, number, number): number
--- @alias LaunchBall fun(
--- self: self,
--- destX: number,
--- destY: number,
--- easingFunc: EasingFunc,
@ -23,9 +26,7 @@ import 'CoreLibs/ui.lua'
import 'utils.lua'
import 'constants.lua'
import 'assets.lua'
import 'draw/player.lua'
import 'draw/overlay.lua'
import 'draw/fielder.lua'
import 'main-menu.lua'
import 'action-queue.lua'
import 'announcer.lua'
@ -35,190 +36,268 @@ import 'dbg.lua'
import 'fielding.lua'
import 'graphics.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
-- selene: allow(shadowing)
-- TODO: Customizable field structure. E.g. stands and ads etc.
local gfx <const>, C <const> = playdate.graphics, C
local announcer = Announcer.new()
local fielding = Fielding.new()
-- 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>
---@alias Team { benchPosition: XyPair }
---@type table<TeamId, Team>
local teams <const> = {
home = {
score = 0,
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
},
away = {
score = 0,
benchPosition = utils.xy(-10, C.Center.y),
},
}
local inning = 1
---@alias TeamId 'home' | 'away'
local battingTeam = teams.away
local offenseState = C.Offense.batting
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
local secondsSinceLastRunnerMove = 0
local secondsSincePitchAllowed = 0
--- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
---@class Settings
---@field finalInning number
---@field userTeam TeamId | nil
---@field awayTeamSprites SpriteCollection
---@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,
-- but they need to be kept in sync with the rest of the globals.
local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
local battingTeamSprites = AwayTeamSprites
local fieldingTeamSprites = HomeTeamSprites
---@field runnerBlipper Blipper
---@field battingTeamSprites SpriteCollection
---@field fieldingTeamSprites SpriteCollection
-------------------------
-- END OF GLOBAL STATE --
-------------------------
---@class Game
---@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) }
---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
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 Pitches <const> = {
-- Fastball
{
x = gfx.animator.new(0, C.PitchStartX, C.PitchStartX, playdate.easingFunctions.linear),
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
},
-- Curve ball
{
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,
local o = setmetatable({
settings = settings,
announcer = announcer,
fielding = fielding,
homeTeamBlipper = homeTeamBlipper,
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(),
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
},
}
}, { __index = Game })
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
local function userIsOn(side)
if UserTeam == nil then
function Game:userIsOn(side)
if self.settings.userTeam == nil then
-- Both teams are NPC-driven
return false, false
end
local ret
if UserTeam == battingTeam then
ret = side == C.Sides.offense
if self.settings.userTeam == self.state.battingTeam then
ret = side == "offense"
else
ret = side == C.Sides.defense
ret = side == "defense"
end
return ret, not ret
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 pitchTypeIndex number | nil
local function pitch(pitchFlyTimeMs, pitchTypeIndex)
Fielding.markIneligible(fielding.fielders.pitcher)
ball.heldBy = nil
catcherThrownBall = false
offenseState = C.Offense.batting
---@param accuracy number The closer to 1.0, the better
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy)
self.state.ball:markUncatchable()
self.state.ball.heldBy = nil
self.state.pitchIsOver = false
self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex]
ball.xAnimator = current.x
ball.yAnimator = current.y or Pitches[1].y
local current = Pitches[pitchTypeIndex](accuracy, self.state.ball)
self.state.ball.xAnimator = current.x
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
-- ball.floatAnimator = current.z
-- ball.floatAnimator:reset()
-- end
if pitchFlyTimeMs then
ball.xAnimator:reset(pitchFlyTimeMs)
ball.yAnimator:reset(pitchFlyTimeMs)
self.state.ball.xAnimator:reset(pitchFlyTimeMs)
self.state.ball.yAnimator:reset(pitchFlyTimeMs)
else
ball.xAnimator:reset()
ball.yAnimator:reset()
self.state.ball.xAnimator:reset()
self.state.ball.yAnimator:reset()
end
secondsSincePitchAllowed = 0
pitchTracker.secondsSinceLastPitch = 0
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()
local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home
local gameOver = inning == 9 and teams.away.score ~= teams.home.score
if not gameOver then
fielding:celebrate()
secondsSinceLastRunnerMove = -7
fielding:benchTo(currentlyFieldingTeam.benchPosition)
announcer:say("SWITCHING SIDES...")
end
local homeScore, awayScore = utils.totalScores(self.state.stats)
local isFinalInning = self.state.inning >= self.settings.finalInning
local gameOver = isFinalInning and self.state.battingTeam == "home" and awayScore ~= homeScore
gameOver = gameOver or self.state.battingTeam == "away" and isFinalInning and homeScore > awayScore
Fielding.celebrate()
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
else
fielding:resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
battingTeam = currentlyFieldingTeam
playdate.timer.new(2000, function()
if battingTeam == teams.home then
battingTeamSprites = HomeTeamSprites
runnerBlipper = HomeTeamBlipper
fieldingTeamSprites = AwayTeamSprites
else
battingTeamSprites = AwayTeamSprites
fieldingTeamSprites = HomeTeamSprites
runnerBlipper = AwayTeamBlipper
end
self.announcer:say("THAT'S THE BALL GAME!")
playdate.timer.new(3000, function()
transitionTo(BoxScore.new(self.state.stats))
end)
return
end
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()
if self.state.battingTeam == "home" then
self.state.battingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.homeTeamBlipper
self.state.fieldingTeamSprites = self.settings.awayTeamSprites
else
self.state.battingTeamSprites = self.settings.awayTeamSprites
self.state.fieldingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.awayTeamBlipper
end
end)
end
local baserunning = Baserunning.new(announcer, nextHalfInning)
local npc = Npc.new(baserunning.runners, fielding.fielders)
---@return TeamInningData
function Game:battingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][self.state.battingTeam]
end
---@return TeamInningData
function Game:fieldingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][getOppositeTeamId(self.state.battingTeam)]
end
---@param scoredRunCount number
local function score(scoredRunCount)
battingTeam.score = battingTeam.score + scoredRunCount
announcer:say("SCORE!")
function Game:score(scoredRunCount)
-- TODO: end the game when it's the bottom of the ninth the home team is now in the lead.
-- outRunners/scoredRunners split
self:battingTeamCurrentInning().score = self:battingTeamCurrentInning().score + scoredRunCount
self.announcer:say("SCORE!")
end
---@param throwFlyMs number
---@return boolean didThrow
local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third]
@ -232,62 +311,73 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
return false
end
-- Power for this throw has already been determined
throwMeter:reset()
fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
secondsSinceLastRunnerMove = 0
offenseState = C.Offense.running
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
self.baserunning.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running
return true
end
local function nextBatter()
secondsSincePitchAllowed = -3
baserunning.batter = nil
function Game:nextBatter()
pitchTracker.secondsSinceLastPitch = -3
self.baserunning.batter = nil
playdate.timer.new(2000, function()
pitchTracker:reset()
if not baserunning.batter then
baserunning:pushNewBatter()
if not self.baserunning.batter then
self.baserunning:pushNewBatter()
end
end)
end
local function walk()
announcer:say("Walk!")
-- TODO? Use baserunning:convertBatterToRunner()
baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home]
offenseState = C.Offense.walking
baserunning.batter = nil
baserunning:updateForcedRunners()
nextBatter()
function Game:walk()
self.announcer:say("Walk!")
-- TODO? Use self.baserunning:convertBatterToRunner()
self.baserunning.batter.nextBase = C.Bases[C.First]
self.baserunning.batter.prevBase = C.Bases[C.Home]
self.state.offenseState = C.Offense.walking
self.baserunning.batter = nil
self.baserunning:updateForcedRunners()
self:nextBatter()
end
local function strikeOut()
local outBatter = baserunning.batter
baserunning.batter = nil
baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
nextBatter()
function Game:strikeOut()
local outBatter = self.baserunning.batter
self.baserunning.batter = nil
self.baserunning:outRunner(outBatter, "Strike out!")
self:nextBatter()
end
local SwingBackDeg <const> = 30
local SwingForwardDeg <const> = 170
---@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)
-- TODO: animate bat-flip or something
batBase.x = baserunning.batter and (baserunning.batter.x + C.BatterHandPos.x) or 0
batBase.y = baserunning.batter and (baserunning.batter.y + C.BatterHandPos.y) or 0
batTip.x = batBase.x + (C.BatLength * math.sin(batAngle))
batTip.y = batBase.y + (C.BatLength * math.cos(batAngle))
self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0
self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0
self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
if
batSpeed > 0
and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H)
and ball.y < 232
and self.state.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
-- Hit!
BatCrackReverb:play()
offenseState = C.Offense.running
self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15)
@ -297,52 +387,86 @@ local function updateBatting(batDeg, batSpeed)
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
local ballDestX = ball.x + (ballVelX * C.BattingPower)
local ballDestY = ball.y + (ballVelY * C.BattingPower)
local ballDestX = self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = self.state.ball.y + (ballVelY * C.BattingPower)
pitchTracker:reset()
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
launchBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler)
local flyTimeMs = 2000
-- 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
announcer:say("Foul ball!")
self.announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out
return
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
---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved
local function updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
if runnersScored ~= 0 then
score(runnersScored)
self:score(runnersScored)
end
return runnerMoved
return runnersStillMoving, secondsSinceLastRunnerMove
end
local function userPitch(throwFly)
local aButton = playdate.buttonIsPressed(playdate.kButtonA)
local bButton = playdate.buttonIsPressed(playdate.kButtonB)
if not aButton and not bButton then
pitch(throwFly, 1)
elseif aButton and not bButton then
pitch(throwFly, 2)
elseif not aButton and bButton then
pitch(throwFly, 3)
elseif aButton and bButton then
pitch(throwFly, 4)
function Game:returnToPitcher()
self.fielding:resetFielderPositions()
actionQueue:upsert("returnToPitcher", 60 * 1000, function()
while not self:pitcherIsOnTheMound() do
coroutine.yield()
end
if not self.baserunning.batter then
self.baserunning:pushNewBatter()
end
self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
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
local function updateGameState()
deltaSeconds = playdate.getElapsedTime() or 0
function Game:updateGameState()
self.state.deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime()
local crankChange = playdate.getCrankChange() --[[@as number]]
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
@ -350,151 +474,183 @@ local function updateGameState()
crankLimited = crankLimited * -1
end
ball:updatePosition()
self.state.ball:updatePosition()
local userOnOffense, userOnDefense = userIsOn(C.Sides.offense)
local userOnOffense, userOnDefense = self:userIsOn("offense")
if userOnDefense then
throwMeter:applyCharge(deltaSeconds, crankLimited)
local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
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
if offenseState == C.Offense.batting then
if ball.y < C.StrikeZoneStartY then
pitchTracker.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then
pitchTracker.recordedPitchX = ball.x
end
if self.state.offenseState == C.Offense.batting then
pitchTracker:recordIfPassed(self.state.ball)
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
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
end
if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then
local outcome = pitchTracker:updatePitchCounts()
if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
if outcome == PitchOutcomes.StrikeOut then
strikeOut()
self:strikeOut()
elseif outcome == PitchOutcomes.Walk then
walk()
self:walk()
end
-- Catcher has the ball. Throw it back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
catcherThrownBall = true
self:returnToPitcher()
self.state.pitchIsOver = true
self.state.didSwing = false
end
local batSpeed
if userOnOffense then
batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
batSpeed = crankLimited
else
batAngleDeg = npc:updateBatAngle(ball, catcherThrownBall, deltaSeconds)
batSpeed = npc:batSpeed() * deltaSeconds
self.state.batAngleDeg =
self.npc:updateBatAngle(self.state.ball, self.state.pitchIsOver, self.state.deltaSeconds)
batSpeed = self.npc:batSpeed() * self.state.deltaSeconds
end
updateBatting(batAngleDeg, batSpeed)
self:updateBatting(self.state.batAngleDeg, batSpeed)
-- Walk batter to the plate
-- TODO: Ensure batter can't be nil, here
baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds)
self.baserunning:updateRunner(self.baserunning.batter, nil, userOnOffense and crankLimited or 0, self.state.deltaSeconds)
if secondsSincePitchAllowed > C.PitchAfterSeconds then
if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly and not buttonControlledThrow(throwFly, true) then
userPitch(throwFly)
local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly and not self:buttonControlledThrow(throwFly, true) then
self:userPitch(throwFly, accuracy)
end
end
else
pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches))
self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), math.random(#Pitches))
end
end
elseif offenseState == C.Offense.running then
elseif self.state.offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited
or function(runner)
return npc:runningSpeed(runner, ball)
return self.npc:runningSpeed(runner, self.state.ball)
end
if updateNonBatterRunners(appliedSpeed) then
secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
fielding:resetFielderPositions()
offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter()
if not baserunning.batter then
baserunning:pushNewBatter()
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false)
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher
self.state.offenseState = C.Offense.batting
self:returnToPitcher()
end
if userOnDefense then
local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly then
self:buttonControlledThrow(throwFly)
end
end
end
elseif offenseState == C.Offense.walking then
if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
offenseState = C.Offense.batting
elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
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
local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds)
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly then
buttonControlledThrow(throwFly)
end
end
if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
if not userOnDefense and offenseState == C.Offense.running then
npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall)
end
end
baserunning:walkAwayOutRunners(deltaSeconds)
actionQueue:runWaiting(deltaSeconds)
self.baserunning:walkAwayOutRunners(self.state.deltaSeconds)
actionQueue:runWaiting(self.state.deltaSeconds)
end
-- TODO: Swappable update() for main menu, etc.
function playdate.update()
function Game:update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
updateGameState()
self:updateGameState()
local ball = self.state.ball
gfx.clear()
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)
GrassBackground:draw(-400, -240)
GrassBackground:draw(-400, -720)
local ballIsHeld = fielding:drawFielders(fieldingTeamSprites, ball)
if offenseState == C.Offense.batting then
gfx.setLineWidth(5)
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
---@type { y: number, drawAction: fun() }[]
local characterDraws = {}
function addDraw(y, drawAction)
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
end
local playerHeightOffset = 10
-- TODO? Scale sprites down as y increases
for _, runner in pairs(baserunning.runners) do
if runner == baserunning.batter then
if batAngleDeg > 50 and batAngleDeg < 200 then
battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
else
battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
local danceOffset = FielderDanceAnimator:currentValue()
---@type Fielder | nil
local ballHeldBy
for _, fielder in pairs(self.fielding.fielders) do
addDraw(fielder.y + danceOffset, function()
local ballHeldByThisFielder =
drawFielder(self.state.fieldingTeamSprites, self.state.ball, fielder.x, fielder.y + danceOffset)
if ballHeldByThisFielder then
ballHeldBy = fielder
end
else
-- TODO? Change blip speed depending on runner speed?
runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
end
end)
end
for _, runner in pairs(baserunning.outRunners) do
battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset)
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
self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
end
else
-- TODO? Change blip speed depending on runner speed?
self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
end
end)
end
if not ballIsHeld then
table.sort(characterDraws, function(a, b)
return a.y < b.y
end)
for _, character in pairs(characterDraws) do
character.drawAction()
end
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.setColor(gfx.kColorWhite)
@ -506,31 +662,30 @@ function playdate.update()
gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(baserunning.runners, fielding.fielders)
drawMinimap(self.baserunning.runners, self.fielding.fielders)
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)
announcer:draw(C.Center.x, 10)
self.announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then
-- luacheck: ignore
playdate.ui.crankIndicator:draw()
end
end
local function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
fielding:resetFielderPositions(teams.home.benchPosition)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function()
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
end
init()
MainMenu.start(Game)

BIN
src/music/MenuMusic.wav Normal file

Binary file not shown.

View File

@ -18,16 +18,18 @@ function Npc.new(runners, fielders)
}, { __index = Npc })
end
-- TODO: FAR more nuanced NPC batting.
---@param ball XyPair
---@param catcherThrownBall boolean
---@param pitchIsOver boolean
---@param deltaSec number
---@return number
function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec)
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
-- luacheck: no unused
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)
else
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
npcBatDeg = 200
npcBatDeg = 230
end
return npcBatDeg
end
@ -119,35 +121,31 @@ end
---@param fielders Fielder[]
---@param fielder Fielder
---@param runners Runner[]
---@param launchBall LaunchBall
local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall)
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners)
if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
return grabCandidate.catchEligible
end)
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
nearestFielder.target = utils.xy(targetX, targetY)
if nearestFielder == fielder then
ball.heldBy = fielder
else
launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
Fielding.markIneligible(nearestFielder)
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
end
end
end
---@param fielder Fielder
---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil }
---@param launchBall LaunchBall
function Npc:fielderAction(fielder, outedSomeRunner, ball, launchBall)
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
function Npc:fielderAction(fielder, outedSomeRunner, ball)
if outedSomeRunner then
-- Delay a little before the next play
playdate.timer.new(750, function()
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall)
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end)
else
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall)
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
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
end
---@alias BaseIndexOrXyPair (integer | XyPair)
---@alias BaseIndexOrXyPair (number | XyPair)
--- NOTE: in addition to the given runners, there is implicitly a batter running from first.
---@param runnerLocations BaseIndexOrXyPair[]
@ -44,7 +44,7 @@ end
---@param expected boolean
---@param fielderWithBallAt XyPair
---@param when integer[][]
---@param when number[][]
function assertRunnerOutCondition(expected, when, fielderWithBallAt)
local msg = expected and "out" or "safe"
for _, runnersOn in ipairs(when) do

View File

@ -1,4 +1,4 @@
-- selene: allow(unscoped_variables)
-- luacheck no new globals
utils = {}
--- @alias XyPair {
@ -70,17 +70,28 @@ end
---@param mover { x: number, y: number }
---@param speed number
---@param target { x: number, y: number }
---@return boolean
function utils.moveAtSpeed(mover, speed, target)
---@param tau number | nil
---@return boolean isStillMoving
function utils.moveAtSpeed(mover, speed, target, tau)
local x, y, distance = utils.normalizeVector(mover.x, mover.y, target.x, target.y)
if distance > 1 then
mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed)
return true
if distance == 0 then
return false
end
return false
if distance > (tau or 1) then
mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed)
else
mover.x = target.x
mover.y = target.y
end
return true
end
function utils.within(within, n1, n2)
return math.abs(n1 - n2) < within
end
---@generic T
@ -164,6 +175,22 @@ function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, li
return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
end
--- Returns true if the given point is anywhere above the given line, with no upper bound.
--- This, if used for home run calculations, would not take into account balls that curve around the foul poles.
---@param point XyPair
---@param linePoints XyPair[]
---@return boolean
function utils.pointIsSquarelyAboveLine(point, linePoints)
for i = 2, #linePoints do
local prev = linePoints[i - 1]
local next = linePoints[i]
if point.x >= prev.x and point.x <= next.x then
return not utils.pointUnderLine(point.x, point.y, prev.x, prev.y, next.x, next.y)
end
end
return false
end
--- Returns true only if the point is below the given line.
---@return boolean
function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
@ -216,76 +243,19 @@ function utils.getNearestOf(array, x, y, extraCondition)
return nearest, nearestDistance
end
-- selene: allow(unscoped_variables)
PitchOutcomes = {
StrikeOut = {},
Walk = {},
}
-- selene: allow(unscoped_variables)
pitchTracker = {
--- 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
if self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
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))
---@param stats Statistics
---@return number homeScore, number awayScore
function utils.totalScores(stats)
local homeScore = 0
local awayScore = 0
for _, inning in pairs(stats.innings) do
homeScore = homeScore + inning.home.score
awayScore = awayScore + inning.away.score
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)
return homeScore, awayScore
end
if not playdate then
return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes, throwMeter = throwMeter }
return utils
end