BatterUp/src/main.lua

714 lines
22 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 'action-queue.lua'
import 'announcer.lua'
import 'dbg.lua'
import 'field.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 PlayerImageBlipper <const> = blipper.new(100, Player, PlayerLowHat)
local FielderDanceAnimator <const> = gfx.animator.new(1, 10, 0, utils.easingHill)
FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1
---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil }
local ballAnimatorX = utils.staticAnimator(C.BallOffscreen)
local ballAnimatorY = utils.staticAnimator(C.BallOffscreen)
local ballSizeAnimator = utils.staticAnimator(C.SmallestBallRadius)
-- TODO? Replace this AND ballSizeAnimator with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk
local ballFloatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill)
local deltaSeconds = 0
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
--- @type Runner[]
local runners <const> = {}
--- @type Runner[]
local outRunners <const> = {}
---@type Runner | nil
local batter = utils.newRunner(runners)
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 batAngleDeg = C.CrankOffsetDeg
---@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),
},
}
---@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
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
---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil
local 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
local function updateForcedRunners()
local stillForced = true
for _, base in ipairs(C.Bases) do
local runnerTargetingBase = utils.getRunnerWithNextBase(runners, base)
if runnerTargetingBase then
if stillForced then
runnerTargetingBase.forcedTo = base
else
runnerTargetingBase.forcedTo = nil
end
else
stillForced = false
end
end
end
---@param runner integer | Runner
local 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
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
Field:benchTo(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
Field:resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
end
end)
end
---@param runnerIndex number
local function score(runnerIndex)
outRunners[#outRunners + 1] = runners[runnerIndex]
table.remove(runners, runnerIndex)
battingTeam.score = battingTeam.score + 1
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
Field:playerThrowTo(targetBase, throwBall, throwFlyMs)
secondsSinceLastRunnerMove = 0
offenseMode = C.Offense.running
return true
end
local function outEligibleRunners(fielder)
local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
local didOutRunner = false
for i, runner in pairs(runners) do
local runnerOnBase = utils.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
local function npcFielderAction(fielder, outedSomeRunner)
if offenseMode ~= C.Offense.running then
return
end
if outedSomeRunner then
-- Delay a little before the next play
playdate.timer.new(750, function()
npc.tryToMakeAPlay(fielder, runners, ball, throwBall)
end)
else
npc.tryToMakeAPlay(fielder, runners, ball, throwBall)
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
local 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
local function nextBatter()
batter = nil
playdate.timer.new(2000, function()
pitchTracker:reset()
if not batter then
batter = utils.newRunner(runners)
end
end)
end
local function walk()
announcer:say("Walk!")
batter.nextBase = C.Bases[C.First]
batter.prevBase = C.Bases[C.Home]
offenseMode = C.Offense.walking
batter = nil
updateForcedRunners()
nextBatter()
end
local function strikeOut()
local outBatter = batter
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 = 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
BatCrackReverb: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)
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
Field:haveSomeoneChase(ballDestX, 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
local function updateRunning(appliedSpeed, forcedOnly)
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
local 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
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 = ballAnimatorX:currentValue()
ball.z = ballFloatAnimator:currentValue()
ball.y = ballAnimatorY:currentValue() + ball.z
ball.size = ballSizeAnimator: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 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 = Field.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(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 > C.ResetFieldersAfterSeconds then
throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
Field:resetFielderPositions()
offenseMode = C.Offense.batting
if not batter then
batter = utils.newRunner(runners)
end
end
end
elseif offenseMode == C.Offense.walking then
updateForcedRunners()
if not updateRunning(C.WalkedRunnerSpeed, true) then
offenseMode = C.Offense.batting
end
end
local fielderHoldingBall = Field:updateFielderPositions(ball, deltaSeconds)
if playerOnDefense then
local throwFly = readThrow()
if throwFly then
buttonControlledThrow(throwFly)
end
end
if fielderHoldingBall then
local outedSomeRunner = outEligibleRunners(fielderHoldingBall)
if playerOnOffense then
npcFielderAction(fielderHoldingBall, outedSomeRunner)
end
end
walkAwayOutRunners()
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(Field.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
local function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
Field: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()