BatterUp/src/main.lua

541 lines
18 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/player.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()
-- TODO: Find a way to get baserunning and npc instantiated closer to the top, here.
-- Currently difficult because they depend on nextHalfInning/each other.
------------------
-- GLOBAL STATE --
------------------
local deltaSeconds = 0
local ball = Ball.new(gfx.animator)
local batBase <const> = utils.xy(C.Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)
local batAngleDeg = C.CrankOffsetDeg
local catcherThrownBall = false
---@alias Team { score: number, benchPosition: XyPair }
---@type table<string, Team>
local teams <const> = {
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
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
-- 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
-------------------------
-- END OF GLOBAL STATE --
-------------------------
local UserTeam <const> = teams.away
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@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 userIsOnOtherSide
local function userIsOn(side)
if UserTeam == nil then
-- Both teams are NPC-driven
return false, false
end
local ret
if UserTeam == battingTeam then
ret = side == C.Sides.offense
else
ret = side == C.Sides.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)
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
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
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)
-- TODO? A dramatic eye-level view on a home-run could be sick.
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(ball)
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 not userOnDefense 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(fieldingTeamSprites, 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
battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
else
battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
end
else
-- TODO? Change blip speed depending on runner speed?
runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
end
end
for _, runner in pairs(baserunning.outRunners) do
battingTeamSprites.frowning: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()