BatterUp/src/main.lua

902 lines
28 KiB
Lua

-- stylua: ignore start
import 'CoreLibs/animation.lua'
import 'CoreLibs/animator.lua'
import 'CoreLibs/easing.lua'
import 'CoreLibs/graphics.lua'
import 'CoreLibs/object.lua'
import 'CoreLibs/timer.lua'
import 'CoreLibs/ui.lua'
--- @alias Runner {
--- x: number,
--- y: number,
--- nextBase: Base,
--- prevBase: Base | nil,
--- forcedTo: Base | nil,
--- }
--- @alias Fielder {
--- x: number,
--- y: number,
--- target: XYPair | nil,
--- speed: number,
--- }
--- @alias EasingFunc fun(number, number, number, number): number
import 'utils.lua'
import 'constants.lua'
import 'assets.lua'
import 'announcer.lua'
import 'dbg.lua'
import 'graphics.lua'
import 'npc.lua'
import 'draw/overlay'
import 'draw/fielder'
-- stylua: ignore end
-- selene: allow(shadowing)
local gfx <const>, C <const> = playdate.graphics, C
local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune.wav")
-- local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune-organy.wav")
local TinnyBackground <const> = playdate.sound.sampleplayer.new("sounds/tinny-background.wav")
local BatCrackSound <const> = playdate.sound.sampleplayer.new("sounds/bat-crack-reverb.wav")
local PlayerImageBlipper <const> = blipper.new(100, Player, PlayerLowHat)
local FielderDanceAnimator <const> = gfx.animator.new(1, 10, 0, utils.easingHill)
FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1
-- selene: allow(unused_variable)
function fieldersDance()
FielderDanceAnimator:reset(C.DanceBounceMs)
end
local ballAnimatorY = gfx.animator.new(0, C.BallOffscreen, C.BallOffscreen, playdate.easingFunctions.linear)
local ballAnimatorX = gfx.animator.new(0, C.BallOffscreen, C.BallOffscreen, playdate.easingFunctions.linear)
---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil }
---@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((ballAnimatorY:currentValue() - C.PitchStartY) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
},
}
local BatterHandPos <const> = utils.xy(10, 25)
local batBase <const> = utils.xy(C.Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)
local ball <const> = {
x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]],
z = 0,
size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]],
}
---@alias Team { score: number, benchPosition: XYPair }
---@type table<string, 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 PlayerTeam <const> = teams.home
local battingTeam = teams.away
local outs = 0
local inning = 1
local offenseMode = C.Offense.batting
---@return boolean playerIsOnSide, boolean playerIsOnOtherSide
function playerIsOn(side)
local ret
if PlayerTeam == battingTeam then
ret = side == C.Sides.offense
else
ret = side == C.Sides.defense
end
return ret, not ret
end
-- TODO? Replace this AND ballSizeAnimator with a ballHeightAnimator
-- ...that might lose some of the magic of both. Compromise available? idk
local ballFloatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill)
local BallSizeMs = 2000
local ballSizeAnimator = gfx.animator.new(BallSizeMs, 9, C.SmallestBallRadius, utils.easingHill)
local deltaSeconds = 0
---@param name string
---@param speed number
---@return Fielder
function newFielder(name, speed)
return {
name = name,
speed = speed,
}
end
---@type table<string, Fielder>
local fielders <const> = {
first = newFielder("First", 40),
second = newFielder("Second", 40),
shortstop = newFielder("Shortstop", 40),
third = newFielder("Third", 40),
pitcher = newFielder("Pitcher", 30),
catcher = newFielder("Catcher", 35),
left = newFielder("Left", 40),
center = newFielder("C.Center", 40),
right = newFielder("Right", 40),
}
--- Actually only benches the infield, because outfielders are far away!
---@param position XYPair
function benchAllFielders(position)
fielders.first.target = position
fielders.second.target = position
fielders.shortstop.target = position
fielders.third.target = position
fielders.pitcher.target = position
fielders.catcher.target = position
end
--- Resets the target positions of all fielders to their defaults (at their field positions).
---@param fromOffTheField XYPair | nil If provided, also sets all runners' current position to one centralized location.
function resetFielderPositions(fromOffTheField)
if fromOffTheField then
for _, fielder in pairs(fielders) do
fielder.x = fromOffTheField.x
fielder.y = fromOffTheField.y
end
end
fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48)
fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30)
fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30)
fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48)
fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y)
fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92)
fielders.left.target = utils.xy(C.Screen.W * -1, C.Screen.H * -0.2)
fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4)
fielders.right.target = utils.xy(C.Screen.W * 2, fielders.left.target.y)
end
--- @type Runner[]
local runners <const> = {}
--- @type Runner[]
local outRunners <const> = {}
---@return Runner
function newRunner()
local new = {
x = C.RightHandedBattersBox.x - 60,
y = C.RightHandedBattersBox.y + 60,
nextBase = C.RightHandedBattersBox,
prevBase = nil,
forcedTo = C.Bases[C.First],
}
runners[#runners + 1] = new
return new
end
---@type Runner | nil
local batter = newRunner()
local throwMeter = 0
--- "Throws" the ball from its current position to the given destination.
---@param destX number
---@param destY number
---@param easingFunc EasingFunc
---@param flyTimeMs number | nil
---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil
function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
ball.heldBy = nil
throwMeter = 0
if not flyTimeMs then
flyTimeMs = utils.distanceBetween(ball.x, ball.y, destX, destY) * 5
end
if customBallScaler then
ballSizeAnimator = customBallScaler
else
-- TODO? Scale based on distance?
ballSizeAnimator = gfx.animator.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
end
ballAnimatorY = gfx.animator.new(flyTimeMs, ball.y, destY, easingFunc)
ballAnimatorX = gfx.animator.new(flyTimeMs, ball.x, destX, easingFunc)
if floaty then
ballFloatAnimator:reset(flyTimeMs)
end
end
-- TODO: Replace with a timer, repeatedly reset instead of setting to 0
local secondsSincePitchAllowed = -5
local catcherThrownBall = false
---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil
function pitch(pitchFlyTimeMs, pitchTypeIndex)
catcherThrownBall = false
offenseMode = C.Offense.batting
local current = Pitches[pitchTypeIndex]
ballAnimatorX = current.x
ballAnimatorY = current.y or Pitches[1].y
-- TODO: This would need to be sanely replaced in throwBall() etc.
-- if current.z then
-- ballFloatAnimator = current.z
-- ballFloatAnimator:reset()
-- end
if pitchFlyTimeMs then
ballAnimatorX:reset(pitchFlyTimeMs)
ballAnimatorY:reset(pitchFlyTimeMs)
else
ballAnimatorX:reset()
ballAnimatorY:reset()
end
secondsSincePitchAllowed = 0
end
--- Returns the base being touched by the player at (x,y), or nil, if no base is being touched
---@param x number
---@param y number
---@return Base | nil
function isTouchingBase(x, y)
return utils.first(C.Bases, function(base)
return utils.distanceBetween(x, y, base.x, base.y) < C.BaseHitbox
end)
end
local BallCatchHitbox = 3
--- Returns true only if the given point is touching the ball at its current position
---@param x number
---@param y number
---@return boolean, number
function isTouchingBall(x, y)
local ballDistance = utils.distanceBetween(x, y, ball.x, ball.y)
return ballDistance < BallCatchHitbox, ballDistance
end
---@param base Base
---@return Runner | nil
function getRunnerWithNextBase(base)
return utils.first(runners, function(runner)
return runner.nextBase == base
end)
end
function updateForcedRunners()
local stillForced = true
for _, base in ipairs(C.Bases) do
local runnerTargetingBase = getRunnerWithNextBase(base)
if runnerTargetingBase then
if stillForced then
runnerTargetingBase.forcedTo = base
else
runnerTargetingBase.forcedTo = nil
end
else
stillForced = false
end
end
end
local ResetFieldersAfterSeconds <const> = 5
-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0
local secondsSinceLastRunnerMove = 0
---@param runner integer | Runner
function outRunner(runner, message)
if type(runner) ~= "number" then
for i, maybe in ipairs(runners) do
if runner == maybe then
runner = i
end
end
end
if type(runner) ~= "number" then
error("Expected runner to have type 'number', but was: " .. type(runner))
end
outRunners[#outRunners + 1] = runners[runner]
table.remove(runners, runner)
outs = outs + 1
updateForcedRunners()
announcer:say(message or "YOU'RE OUT!")
if outs == 3 then
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
fieldersDance()
secondsSinceLastRunnerMove = -7
benchAllFielders(currentlyFieldingTeam.benchPosition)
announcer:say("SWITCHING SIDES...")
end
while #runners > 0 do
outRunners[#outRunners + 1] = table.remove(runners, #runners)
end
-- Delay to keep end-of-inning on the scoreboard for a few seconds
playdate.timer.new(3000, function()
outs = 0
battingTeam = currentlyFieldingTeam
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
else
resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
end
end)
end
end
---@param runnerIndex number
function score(runnerIndex)
outRunners[#outRunners + 1] = runners[runnerIndex]
table.remove(runners, runnerIndex)
battingTeam.score = battingTeam.score + 1
announcer:say("SCORE!")
end
---@return Base[]
function getForcedOutTargets()
local targets = {}
for _, base in ipairs(C.Bases) do
local runnerTargetingBase = getRunnerWithNextBase(base)
if runnerTargetingBase then
targets[#targets + 1] = base
else
return targets
end
end
return targets
end
--- Returns the position,distance of the basest closest to the runner furthest from a base
---@return Base | nil, number | nil
function getBaseOfStrandedRunner()
local farRunnersBase, farDistance
for _, runner in pairs(runners) do
if runner ~= batter then
local nearestBase, distance = utils.getNearestOf(C.Bases, runner.x, runner.y)
if farRunnersBase == nil or farDistance < distance then
farRunnersBase = nearestBase
farDistance = distance
end
end
end
return farRunnersBase, farDistance
end
--- Returns x,y of the out target
---@return number|nil, number|nil
function getNextOutTarget()
-- TODO: Handle missed throws, check for fielders at target, etc.
local targets = getForcedOutTargets()
if #targets ~= 0 then
return targets[#targets].x, targets[#targets].y
end
local baseCloseToStrandedRunner = getBaseOfStrandedRunner()
if baseCloseToStrandedRunner then
return baseCloseToStrandedRunner.x, baseCloseToStrandedRunner.y
end
end
---@param fielder Fielder
function tryToMakeAnOut(fielder)
local targetX, targetY = getNextOutTarget()
if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
nearestFielder.target = utils.xy(targetX, targetY)
if nearestFielder == fielder then
ball.heldBy = fielder
else
throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
end
end
end
function readThrow()
if throwMeter > C.ThrowMeterMax then
return (C.PitchFlyMs / (throwMeter / C.ThrowMeterMax))
end
return nil
end
---@param thrower Fielder
---@param throwFlyMs number
---@return boolean didThrow
function buttonControlledThrow(thrower, throwFlyMs, forbidThrowHome)
local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third]
elseif playdate.buttonIsPressed(playdate.kButtonUp) then
targetBase = C.Bases[C.Second]
elseif playdate.buttonIsPressed(playdate.kButtonRight) then
targetBase = C.Bases[C.First]
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
targetBase = C.Bases[C.Home]
else
return false
end
local closestFielder = utils.getNearestOf(fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= thrower
end)
throwBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
closestFielder.target = targetBase
secondsSinceLastRunnerMove = 0
offenseMode = C.Offense.running
return true
end
function outEligibleRunners(fielder)
local touchedBase = isTouchingBase(fielder.x, fielder.y)
local didOutRunner = false
for i, runner in pairs(runners) do
local runnerOnBase = isTouchingBase(runner.x, runner.y)
if -- Force out
touchedBase
and runner.prevBase -- Make sure the runner is not standing at home
and runner.forcedTo == touchedBase
and touchedBase ~= runnerOnBase
-- Tag out
or not runnerOnBase and utils.distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < C.TagDistance
then
outRunner(i)
didOutRunner = true
end
end
return didOutRunner
end
function updateNpcFielder(fielder, outedSomeRunner)
if offenseMode ~= C.Offense.running then
return
end
if outedSomeRunner then
playdate.timer.new(750, function()
tryToMakeAnOut(fielder)
end)
else
tryToMakeAnOut(fielder)
end
end
---@param fielder Fielder
function updateFielder(fielder)
if fielder.target ~= nil then
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
fielder.target = nil
end
end
if not isTouchingBall(fielder.x, fielder.y) then
return
end
local outedSomeRunner = outEligibleRunners(fielder)
if playerIsOn(C.Sides.defense) then
local throwFly = readThrow()
if throwFly then
buttonControlledThrow(fielders.pitcher, throwFly)
end
else
updateNpcFielder(fielder, outedSomeRunner)
end
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 appliedSpeed number
---@return boolean
function updateRunner(runner, runnerIndex, appliedSpeed)
local autoRunSpeed = 20 * deltaSeconds
--autoRunSpeed = 140
if not runner or not runner.nextBase then
return false
end
local nearestBase, nearestBaseDistance = utils.getNearestOf(C.Bases, runner.x, runner.y)
if
nearestBaseDistance < 5
and runnerIndex ~= nil
and runner ~= batter --runner.prevBase
and runner.nextBase == C.Bases[C.Home]
and nearestBase == C.Bases[C.Home]
then
score(runnerIndex)
end
local nb = runner.nextBase
local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y)
if distance < 2 then
runner.nextBase = C.NextBaseMap[runner.nextBase]
runner.forcedTo = nil
return false
end
local prevX, prevY = runner.x, runner.y
local mult = 1
if appliedSpeed < 0 then
if runner.prevBase then
mult = -1
else
-- Don't allow running backwards when approaching the plate
appliedSpeed = 0
end
end
local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
or nearestBaseDistance < 5 and 0
or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
mult = autoRun + (appliedSpeed / 20)
runner.x = runner.x - (x * mult)
runner.y = runner.y - (y * mult)
return prevX ~= runner.x or prevY ~= runner.y
end
---@type number
local batAngleDeg
function nextBatter()
batter = nil
playdate.timer.new(2000, function()
pitchTracker:reset()
if not batter then
batter = newRunner()
end
end)
end
function walk()
announcer:say("Walk!")
fielders.first.target = C.Bases[C.First]
batter.nextBase = C.Bases[C.First]
batter.prevBase = C.Bases[C.Home]
offenseMode = C.Offense.walking
batter = nil
updateForcedRunners()
nextBatter()
end
function strikeOut()
local outBatter = batter
batter = nil
outRunner(outBatter --[[@as Runner]], "Strike out!")
nextBatter()
end
---@param batDeg number
function updateBatting(batDeg, batSpeed)
if ball.y < C.BallOffscreen then
ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue()
ball.size = C.SmallestBallRadius -- ballFloatAnimator:currentValue()
end
local batAngle = math.rad(batDeg)
-- TODO: animate bat-flip or something
batBase.x = batter and (batter.x + BatterHandPos.x) or 0
batBase.y = batter and (batter.y + BatterHandPos.y) or 0
batTip.x = batBase.x + (C.BatLength * math.sin(batAngle))
batTip.y = 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
then
BatCrackSound:play()
offenseMode = C.Offense.running
local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15)
local ballVelX = mult * 10 * math.sin(ballAngle)
local ballVelY = mult * 5 * math.cos(ballAngle)
if ballVelY > 0 then
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
local ballDestX = ball.x + (ballVelX * C.BattingPower)
local ballDestY = ball.y + (ballVelY * C.BattingPower)
-- Hit!
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
throwBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler)
fielders.first.target = C.Bases[C.First]
batter.nextBase = C.Bases[C.First]
batter.prevBase = C.Bases[C.Home]
updateForcedRunners()
batter.forcedTo = C.Bases[C.First]
batter = nil -- Demote batter to a mere runner
local chasingFielder = utils.getNearestOf(fielders, ballDestX, ballDestY)
chasingFielder.target = { x = ballDestX, y = ballDestY }
end
end
--- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number
---@return boolean
function updateRunning(appliedSpeed, forcedOnly)
ball.size = ballSizeAnimator:currentValue()
local runnerMoved = false
-- TODO: Filter for the runner closest to the currently-held direction button
for runnerIndex, runner in ipairs(runners) do
if runner ~= batter and (not forcedOnly or runner.forcedTo) then
runnerMoved = updateRunner(runner, runnerIndex, appliedSpeed) or runnerMoved
end
end
return runnerMoved
end
function walkAwayOutRunners()
for i, runner in ipairs(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
table.remove(outRunners, i)
end
end
end
function playerPitch(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)
end
end
function updateGameState()
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)
if crankChange < 0 then
crankLimited = crankLimited * -1
end
if ball.heldBy then
ball.x = ball.heldBy.x
ball.y = ball.heldBy.y
else
ball.x = ballAnimatorX:currentValue()
ball.z = ballFloatAnimator:currentValue()
ball.y = ballAnimatorY:currentValue() + ball.z
end
local playerOnOffense, playerOnDefense = playerIsOn(C.Sides.offense)
if playerOnDefense then
throwMeter = math.max(0, throwMeter - (deltaSeconds * C.ThrowMeterDrainPerSec))
throwMeter = throwMeter + math.abs(crankLimited)
end
if offenseMode == C.Offense.batting then
if ball.y < C.StrikeZoneStartY then
pitchTracker.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then
pitchTracker.recordedPitchX = ball.x
end
local pitcher = fielders.pitcher
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitchStartX, C.PitchStartY) < C.BaseHitbox then
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
end
if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then
local outcome = pitchTracker:updatePitchCounts()
if outcome == PitchOutcomes.StrikeOut then
strikeOut()
elseif outcome == PitchOutcomes.Walk then
walk()
end
throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
catcherThrownBall = true
end
local batSpeed
if playerOnOffense then
batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
batSpeed = crankLimited
else
batAngleDeg = npc.updateBatAngle(ball, catcherThrownBall, deltaSeconds)
batSpeed = npc.batSpeed() * deltaSeconds
end
updateBatting(batAngleDeg, batSpeed)
-- TODO: Ensure batter can't be nil, here
updateRunner(batter, nil, crankLimited)
if secondsSincePitchAllowed > C.PitchAfterSeconds then
if playerOnDefense then
local throwFly = readThrow()
if throwFly and not buttonControlledThrow(pitcher, throwFly, true) then
playerPitch(throwFly)
end
else
pitch(C.PitchFlyMs, math.random(#Pitches))
end
end
elseif offenseMode == C.Offense.running then
local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(runners)
if updateRunning(appliedSpeed) then
secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then
throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
resetFielderPositions()
offenseMode = C.Offense.batting
if not batter then
batter = newRunner()
end
end
end
elseif offenseMode == C.Offense.walking then
updateForcedRunners()
if not updateRunning(C.WalkedRunnerSpeed, true) then
offenseMode = C.Offense.batting
end
end
for _, fielder in pairs(fielders) do
updateFielder(fielder)
end
walkAwayOutRunners()
end
function playdate.update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
updateGameState()
gfx.clear()
gfx.setColor(gfx.kColorBlack)
local offsetX, offsetY = 0, 0
if ball.x < C.BallOffscreen then
offsetX, offsetY = getDrawOffset(C.Screen.W, C.Screen.H, ball.x, ball.y)
gfx.setDrawOffset(offsetX, offsetY)
end
GrassBackground:draw(-400, -240)
local fielderDanceHeight = FielderDanceAnimator:currentValue()
local ballIsHeld = false
for _, fielder in pairs(fielders) do
ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld
end
if offenseMode == C.Offense.batting then
gfx.setLineWidth(5)
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
end
if playdate.isCrankDocked() then
playdate.ui.crankIndicator:draw()
end
-- TODO? Scale sprites down as y increases
for _, runner in pairs(runners) do
if runner == batter then
if batAngleDeg > 50 and batAngleDeg < 200 then
PlayerBack:draw(runner.x, runner.y)
else
Player:draw(runner.x, runner.y)
end
else
-- TODO? Change blip speed depending on runner speed?
PlayerImageBlipper:draw(false, runner.x, runner.y)
end
end
for _, runner in pairs(outRunners) do
PlayerFrown:draw(runner.x, runner.y)
end
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
gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(runners)
end
drawScoreboard(0, C.Screen.H * 0.77, teams, outs, battingTeam, inning)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
announcer:draw(C.Center.x, 10)
end
function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
resetFielderPositions(teams.home.benchPosition)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function()
throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
end
init()