BatterUp/src/main.lua

562 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 Fielder {
--- x: number,
--- y: number,
--- target: XYPair | nil,
--- speed: number,
--- }
--- @alias EasingFunc fun(number, number, number, number): number
--- @alias ThrowBall 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 'action-queue.lua'
import 'announcer.lua'
import 'baserunning.lua'
import 'dbg.lua'
import 'fielding.lua'
import 'graphics.lua'
import 'npc.lua'
import 'draw/overlay.lua'
import 'draw/fielder.lua'
-- stylua: ignore end
-- selene: allow(shadowing)
local gfx <const>, C <const> = playdate.graphics, C
local announcer = Announcer:new()
local baserunning = Baserunning.new(announcer)
local fielding = Fielding.new()
---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil }
local deltaSeconds = 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]],
xAnimator = utils.staticAnimator(C.BallOffscreen),
yAnimator = utils.staticAnimator(C.BallOffscreen),
-- TODO? Replace these with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
floatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill),
}
---@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.away
local battingTeam = teams.away
local outs = 0
local inning = 1
local offenseState = C.Offense.batting
local throwMeter = 0
-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0
local secondsSinceLastRunnerMove = 0
-- TODO: Replace with a timer, repeatedly reset instead of setting to 0
local secondsSincePitchAllowed = -5
local catcherThrownBall = false
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 batAngleDeg = C.CrankOffsetDeg
local PlayerImageBlipper <const> = blipper.new(100, Player, PlayerLowHat)
local FielderDanceAnimator <const> = gfx.animator.new(1, 10, 0, utils.easingHill)
FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1
---@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 playerIsOnSide, boolean playerIsOnOtherSide
local 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
--- "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
local 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
ball.sizeAnimator = customBallScaler
else
-- TODO? Scale based on distance?
ball.sizeAnimator = gfx.animator.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
end
ball.yAnimator = gfx.animator.new(flyTimeMs, ball.y, destY, easingFunc)
ball.xAnimator = gfx.animator.new(flyTimeMs, ball.x, destX, easingFunc)
if floaty then
ball.floatAnimator:reset(flyTimeMs)
end
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 throwBall() 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
---@param runner integer | Runner
---@param message string | nil
local function outRunner(runner, message)
baserunning:outRunner(runner, message)
if baserunning.outs < 3 then
return
end
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
FielderDanceAnimator:reset(C.DanceBounceMs)
secondsSinceLastRunnerMove = -7
fielding:benchTo(currentlyFieldingTeam.benchPosition)
announcer:say("SWITCHING SIDES...")
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
fielding:resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
end
end)
end
---@param scoredRunCount number
local function score(scoredRunCount)
battingTeam.score = battingTeam.score + scoredRunCount
announcer:say("SCORE!")
end
local function readThrow()
if throwMeter > C.ThrowMeterMax then
return (C.PitchFlyMs / (throwMeter / C.ThrowMeterMax))
end
return nil
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 = 0
fielding:playerThrowTo(targetBase, throwBall, throwFlyMs)
secondsSinceLastRunnerMove = 0
offenseState = C.Offense.running
return true
end
local function nextBatter()
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
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 + BatterHandPos.x) or 0
batBase.y = baserunning.batter and (baserunning.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
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)
-- 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)
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:updateRunning(appliedSpeed, forcedOnly, deltaSeconds)
if runnersScored ~= 0 then
score(runnersScored)
end
return runnerMoved
end
local 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
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 playerOnOffense, playerOnDefense = playerIsOn(C.Sides.offense)
if playerOnDefense then
throwMeter = math.max(0, throwMeter - (deltaSeconds * C.ThrowMeterDrainPerSec))
throwMeter = throwMeter + math.abs(crankLimited * C.PitchPower)
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.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)
-- 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 playerOnDefense then
local throwFly = readThrow()
if throwFly and not buttonControlledThrow(throwFly, true) then
playerPitch(throwFly)
end
else
pitch(C.PitchFlyMs, math.random(#Pitches))
end
end
elseif offenseState == C.Offense.running then
local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(baserunning.runners)
if updateNonBatterRunners(appliedSpeed) then
secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
fielding:resetFielderPositions()
offenseState = C.Offense.batting
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 playerOnDefense then
local throwFly = readThrow()
if throwFly then
buttonControlledThrow(throwFly)
end
end
if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
if playerOnOffense then
npc.fielderAction(fielding, baserunning, offenseState, fielderHoldingBall, outedSomeRunner, ball, throwBall)
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 fielderDanceHeight = FielderDanceAnimator:currentValue()
local ballIsHeld = false
for _, fielder in pairs(fielding.fielders) do
ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld
end
if offenseState == 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(baserunning.runners) do
if runner == baserunning.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(baserunning.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(baserunning.runners, fielding.fielders)
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
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()
throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
end
init()