BatterUp/src/main.lua

520 lines
17 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'
-- stylua: ignore end
--- @alias EasingFunc fun(number, number, number, number): number
--- @alias LaunchBall fun(
--- destX: number,
--- destY: number,
--- easingFunc: EasingFunc,
--- flyTimeMs: number | nil,
--- floaty: boolean | nil,
--- customBallScaler: pd_animator | nil,
--- )
-- stylua: ignore start
import 'utils.lua'
import 'constants.lua'
import 'assets.lua'
import 'draw/overlay.lua'
import 'draw/fielder.lua'
import 'action-queue.lua'
import 'announcer.lua'
import 'ball.lua'
import 'baserunning.lua'
import 'dbg.lua'
import 'fielding.lua'
import 'graphics.lua'
import 'npc.lua'
-- stylua: ignore end
-- selene: allow(shadowing)
local gfx <const>, C <const> = playdate.graphics, C
local announcer = Announcer.new()
local fielding = Fielding.new()
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
local deltaSeconds = 0
local ball = Ball.new(gfx.animator)
---@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 UserTeam <const> = teams.away
local battingTeam = teams.away
local inning = 1
local offenseState = C.Offense.batting
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
local secondsSinceLastRunnerMove = 0
local secondsSincePitchAllowed = -5
local catcherThrownBall = false
local batBase <const> = utils.xy(C.Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)
local batAngleDeg = C.CrankOffsetDeg
---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | 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((ball.yAnimator:currentValue() - C.PitchStartY) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
},
}
---@return boolean userIsOnSide, boolean playerIsOnOtherSide
local function userIsOn(side)
local ret
if UserTeam == battingTeam then
ret = side == C.Sides.offense
else
ret = side == C.Sides.defense
end
return ret, not ret
end
--- Launches 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
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)
catcherThrownBall = false
offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex]
ball.xAnimator = current.x
ball.yAnimator = current.y or Pitches[1].y
-- TODO: This would need to be sanely replaced in launchBall() etc.
-- if current.z then
-- ball.floatAnimator = current.z
-- ball.floatAnimator:reset()
-- end
if pitchFlyTimeMs then
ball.xAnimator:reset(pitchFlyTimeMs)
ball.yAnimator:reset(pitchFlyTimeMs)
else
ball.xAnimator:reset()
ball.yAnimator:reset()
end
secondsSincePitchAllowed = 0
end
local function 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
-- TODO: Make the overlay handle its own dang delay.
-- Delay to keep end-of-inning on the scoreboard for a few seconds
playdate.timer.new(3000, function()
battingTeam = currentlyFieldingTeam
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
else
fielding:resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
end
end)
end
local baserunning = Baserunning.new(announcer, nextHalfInning)
local npc = Npc.new(baserunning.runners, fielding.fielders)
---@param scoredRunCount number
local function score(scoredRunCount)
battingTeam.score = battingTeam.score + scoredRunCount
announcer:say("SCORE!")
end
---@param throwFlyMs number
---@return boolean didThrow
local function buttonControlledThrow(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
-- Power for this throw has already been determined
throwMeter:reset()
fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
secondsSinceLastRunnerMove = 0
offenseState = C.Offense.running
return true
end
local function nextBatter()
secondsSincePitchAllowed = -3
baserunning.batter = nil
playdate.timer.new(2000, function()
pitchTracker:reset()
if not baserunning.batter then
baserunning.batter = baserunning:newRunner()
end
end)
end
local function walk()
announcer:say("Walk!")
baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home]
offenseState = C.Offense.walking
baserunning.batter = nil
baserunning:updateForcedRunners()
nextBatter()
end
local function strikeOut()
local outBatter = baserunning.batter
baserunning.batter = nil
baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
nextBatter()
end
---@param batDeg number
local function updateBatting(batDeg, batSpeed)
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))
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
-- Hit!
BatCrackReverb:play()
offenseState = 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)
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)
if utils.isFoulBall(ballDestX, ballDestY) then
announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out
return
end
baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home]
baserunning:updateForcedRunners()
baserunning.batter.forcedTo = C.Bases[C.First]
baserunning.batter = nil -- Demote batter to a mere runner
fielding:haveSomeoneChase(ballDestX, ballDestY)
end
end
---@param appliedSpeed number
---@return boolean someRunnerMoved
local function updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
if runnersScored ~= 0 then
score(runnersScored)
end
return runnerMoved
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)
end
end
local 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
ball.size = C.SmallestBallRadius
else
ball.x = ball.xAnimator:currentValue()
ball.z = ball.floatAnimator:currentValue()
ball.y = ball.yAnimator:currentValue() + ball.z
ball.size = ball.sizeAnimator:currentValue()
end
local userOnOffense, userOnDefense = userIsOn(C.Sides.offense)
if userOnDefense then
throwMeter:applyCharge(deltaSeconds, crankLimited)
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
local pitcher = fielding.fielders.pitcher
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
end
if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then
local outcome = pitchTracker:updatePitchCounts()
if outcome == PitchOutcomes.StrikeOut then
strikeOut()
elseif outcome == PitchOutcomes.Walk then
walk()
end
-- Catcher has the ball. Throw it back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
catcherThrownBall = true
end
local batSpeed
if userOnOffense then
batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
batSpeed = crankLimited
else
batAngleDeg = npc:updateBatAngle(ball, catcherThrownBall, deltaSeconds)
batSpeed = npc:batSpeed() * deltaSeconds
end
updateBatting(batAngleDeg, batSpeed)
-- Walk batter to the plate
-- TODO: Ensure batter can't be nil, here
baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds)
if secondsSincePitchAllowed > C.PitchAfterSeconds then
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly and not buttonControlledThrow(throwFly, true) then
userPitch(throwFly)
end
else
pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches))
end
end
elseif offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited or npc:runningSpeed()
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:resetFielderPositions()
offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter()
if not baserunning.batter then
baserunning.batter = baserunning:newRunner()
end
end
end
elseif offenseState == C.Offense.walking then
if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
offenseState = C.Offense.batting
end
end
local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds)
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly then
buttonControlledThrow(throwFly)
end
end
if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
if userOnOffense then
npc:fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, launchBall)
end
end
baserunning:walkAwayOutRunners(deltaSeconds)
actionQueue:runWaiting(deltaSeconds)
end
function playdate.update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
updateGameState()
gfx.clear()
gfx.setColor(gfx.kColorBlack)
local offsetX, offsetY = getDrawOffset(ball.x, ball.y)
gfx.setDrawOffset(offsetX, offsetY)
GrassBackground:draw(-400, -240)
local ballIsHeld = fielding:drawFielders(ball)
if offenseState == C.Offense.batting then
gfx.setLineWidth(5)
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
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
PlayerBack:draw(runner.x, runner.y - playerHeightOffset)
else
Player:draw(runner.x, runner.y - playerHeightOffset)
end
else
-- TODO? Change blip speed depending on runner speed?
PlayerImageBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
end
end
for _, runner in pairs(baserunning.outRunners) do
PlayerFrown:draw(runner.x, runner.y - playerHeightOffset)
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(baserunning.runners, fielding.fielders)
end
drawScoreboard(0, C.Screen.H * 0.77, teams, baserunning.outs, battingTeam, inning)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then
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.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()