BatterUp/src/main.lua

623 lines
20 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'
import 'CoreLibs/utilities/where.lua'
-- stylua: ignore end
--- @alias Scene { update: fun(self: self) }
--- @alias EasingFunc fun(number, number, number, number): number
---@class InputHandler
---@field update fun(self, deltaSeconds: number)
---@field updateBatAngle fun(self, ball: Ball, pitchIsOver: boolean, deltaSeconds: number)
---@field runningSpeed fun(self, runner: Runner, ball: Ball)
---@field pitch fun(self)
---@field fielderAction fun(self, fielderHoldingBall: Fielder | nil, outedSomeRunner: boolean, ball: Ball)
--- @alias LaunchBall fun(
--- self: self,
--- 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 'main-menu.lua'
import 'action-queue.lua'
import 'announcer.lua'
import 'ball.lua'
import 'baserunning.lua'
import 'batting.lua'
import 'dbg.lua'
import 'fielding.lua'
import 'graphics.lua'
import 'npc.lua'
import 'pitching.lua'
import 'statistics.lua'
import 'user-input.lua'
import 'draw/ball.lua'
import 'draw/box-score.lua'
import 'draw/fans.lua'
import 'draw/characters.lua'
import 'draw/overlay.lua'
import 'draw/panner.lua'
import 'draw/character-sprites.lua'
import 'draw/characters.lua'
import 'draw/throw-meter.lua'
import 'draw/transitions.lua'
-- stylua: ignore end
-- TODO: Customizable field structure. E.g. stands and ads etc.
---@type pd_graphics_lib
local gfx <const> = playdate.graphics
local C <const> = C
---@alias Team { benchPosition: XyPair }
---@type table<TeamId, Team>
local teams <const> = {
home = {
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
},
away = {
benchPosition = utils.xy(-10, C.Center.y),
},
}
---@alias TeamId 'home' | 'away'
--- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
---@class Settings
---@field finalInning number
---@field userTeam TeamId | nil
---@field awayTeamSpriteGroup SpriteCollection
---@field homeTeamSpriteGroup SpriteCollection
---@class MutableState
---@field deltaSeconds number
---@field ball Ball
---@field battingTeam TeamId
---@field pitchIsOver boolean
---@field didSwing boolean
---@field offenseState OffenseState
---@field inning number
---@field stats Statistics
--- Ephemeral data ONLY used during rendering
---@class RenderState
---@field bat BatRenderState
---@class Game
---@field private settings Settings
---@field private announcer Announcer
---@field private fielding Fielding
---@field private baserunning Baserunning
---@field private batting Batting
---@field private characters Characters
---@field private npc InputHandler
---@field private userInput InputHandler
---@field private panner Panner
---@field private state MutableState
Game = {}
---@param settings Settings
---@param announcer Announcer | nil
---@param fielding Fielding | nil
---@param baserunning Baserunning | nil
---@param npc InputHandler | nil
---@param state MutableState | nil
---@return Game
function Game.new(settings, announcer, fielding, baserunning, npc, state)
announcer = announcer or Announcer.new()
fielding = fielding or Fielding.new()
settings.userTeam = "away"
local battingTeam = "away"
local ball = Ball.new(gfx.animator)
local o = setmetatable({
settings = settings,
announcer = announcer,
fielding = fielding,
panner = Panner.new(ball),
state = state or {
deltaSeconds = 0,
ball = ball,
battingTeam = battingTeam,
offenseState = C.Offense.batting,
inning = 1,
pitchIsOver = true,
didSwing = false,
stats = Statistics.new(),
},
}, { __index = Game })
o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning()
end)
o.batting = Batting.new(o.baserunning)
o.userInput = UserInput.new(function(throwFly, forbidThrowHome)
return o:buttonControlledThrow(throwFly, forbidThrowHome)
end)
o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)
o.fielding:resetFielderPositions(teams.home.benchPosition, settings.userTeam == nil)
playdate.timer.new(settings.userTeam == nil and 10 or 2000, function()
o:returnToPitcher()
end)
o.characters = Characters.new(settings.homeTeamSpriteGroup, settings.awayTeamSpriteGroup)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
return o
end
---@param teamId TeamId
---@return TeamId
local function getOppositeTeamId(teamId)
if teamId == "home" then
return "away"
elseif teamId == "away" then
return "home"
else
error("Unknown TeamId: " .. (teamId or "nil"))
end
end
function Game:getFieldingTeam()
return teams[getOppositeTeamId(self.state.battingTeam)]
end
---@param side Side
---@return boolean userIsOnSide, boolean userIsOnOtherSide
function Game:userIsOn(side)
if self.settings.userTeam == nil then
-- Both teams are NPC-driven
return false, false
end
local ret
if self.settings.userTeam == self.state.battingTeam then
ret = side == "offense"
else
ret = side == "defense"
end
return ret, not ret
end
---@return InputHandler offense, InputHandler defense
function Game:currentInputHandlers()
local userOnOffense, userOnDefense = self:userIsOn("offense")
local offenseInput = userOnOffense and self.userInput or self.npc
local defenseInput = userOnDefense and self.userInput or self.npc
offenseInput:update(self.state.deltaSeconds)
defenseInput:update(self.state.deltaSeconds)
return offenseInput, defenseInput
end
---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil
---@param accuracy number The closer to 1.0, the better
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy)
if pitchTypeIndex == nil then
return -- No throw!
end
self.state.ball:markUncatchable()
self.state.ball.heldBy = nil
self.state.pitchIsOver = false
self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex](accuracy, self.state.ball)
self.state.ball.xAnimator = current.x
self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y
-- TODO: This would need to be sanely replaced in ball:launch() etc.
-- if current.z then
-- ball.floatAnimator = current.z
-- ball.floatAnimator:reset()
-- end
if pitchFlyTimeMs then
self.state.ball.xAnimator:reset(pitchFlyTimeMs)
self.state.ball.yAnimator:reset(pitchFlyTimeMs)
else
self.state.ball.xAnimator:reset()
self.state.ball.yAnimator:reset()
end
pitchTracker.secondsSinceLastPitch = 0
end
function Game:pitcherIsOnTheMound()
return utils.distanceBetweenPoints(self.fielding.fielders.pitcher, C.PitcherStartPos) < C.BaseHitbox
end
function Game:pitcherIsReady()
local pitcher = self.fielding.fielders.pitcher
return self:pitcherIsOnTheMound()
and (
self.state.ball.heldBy == pitcher
or utils.distanceBetweenPoints(pitcher, self.state.ball) < C.BallCatchHitbox
or utils.distanceBetweenPoints(self.state.ball, C.PitchStart) < 2
)
end
function Game:checkForGameOver()
local state = self.state
if state.stats:gameIsOver(state.inning, self.settings.finalInning, state.battingTeam) then
self.announcer:say("THAT'S THE BALL GAME!")
playdate.timer.new(3000, function()
transitionTo(BoxScore.new(self.state.stats))
end)
return true
end
return false
end
function Game:nextHalfInning()
pitchTracker:reset()
Fielding.celebrate()
if self:checkForGameOver() then
return
end
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...")
self.fielding:resetFielderPositions()
if self.state.battingTeam == "home" then
self.state.inning = self.state.inning + 1
self.state.stats:pushInning()
end
playdate.timer.new(2000, function()
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
end)
end
---@return TeamInningData
function Game:battingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][self.state.battingTeam]
end
---@return TeamInningData
function Game:fieldingTeamCurrentInning()
return self.state.stats.innings[self.state.inning][getOppositeTeamId(self.state.battingTeam)]
end
---@param scoredRunCount number
function Game:score(scoredRunCount)
local battingTeamStats = self:battingTeamCurrentInning()
battingTeamStats.score = battingTeamStats.score + scoredRunCount
self.announcer:say("SCORE!")
self:checkForGameOver()
end
---@param throwFlyMs number
---@return boolean didThrow
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Third
elseif playdate.buttonIsPressed(playdate.kButtonUp) then
targetBase = C.Second
elseif playdate.buttonIsPressed(playdate.kButtonRight) then
targetBase = C.First
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
targetBase = C.Home
else
return false
end
self.fielding:userThrowTo(C.Bases[targetBase], self.state.ball, throwFlyMs)
self.baserunning.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running
return true
end
function Game:nextBatter()
pitchTracker.secondsSinceLastPitch = -3
self.baserunning.batter = nil
playdate.timer.new(2000, function()
pitchTracker:reset()
if not self.baserunning.batter then
self.baserunning:pushNewBatter()
end
end)
end
function Game:walk()
self.announcer:say("Walk!")
-- TODO? Use self.baserunning:convertBatterToRunner()
self.baserunning.batter.nextBase = C.Bases[C.First]
self.baserunning.batter.prevBase = C.Bases[C.Home]
self.state.offenseState = C.Offense.walking
self.baserunning.batter = nil
self.baserunning:updateForcedRunners()
self:nextBatter()
end
function Game:strikeOut()
local outBatter = self.baserunning.batter
self.baserunning.batter = nil
self.baserunning:outRunner(outBatter, "Strike out!")
self:nextBatter()
end
function Game:saveToFile()
playdate.datastore.write({ currentGame = self.state }, "data", true)
end
function Game.load()
local loaded = playdate.datastore.read("data")
---@type Game
local loadedGame = loaded.currentGame
loadedGame.state.ball = Ball.new(gfx.animator)
local settings = {
homeTeamSpriteGroup = HomeTeamSpriteGroup,
awayTeamSpriteGroup = AwayTeamSpriteGroup,
finalInning = loadedGame.settings.finalInning,
}
local ret = Game.new(settings, nil, loadedGame.fielding, nil, nil, loadedGame.state)
ret.baserunning.outs = loadedGame.outs
return ret
end
---@param offenseHandler InputHandler
function Game:updateBatting(offenseHandler)
local ball = self.state.ball
local batDeg, batSpeed = offenseHandler:updateBatAngle(ball, self.state.pitchIsOver, self.state.deltaSeconds)
local ballDest, isSwinging, mult = self.batting:checkForHit(batDeg, batSpeed, ball)
self.state.didSwing = self.state.didSwing or (isSwinging and not self.state.pitchIsOver)
if not ballDest then
return
end
-- Hit!
-- TODO: animate bat-flip or something
local isFlyBall = math.random() > 0.5
self:saveToFile()
BatCrackReverb:play()
self.state.offenseState = C.Offense.running
pitchTracker:reset()
local flyTimeMs = 8000
-- TODO? A dramatic eye-level view on a home-run could be sick.
local battingTeamStats = self:battingTeamCurrentInning()
battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest
if utils.isFoulBall(ballDest) then
self.announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out
return
end
local isPastOutfieldWall, nearbyPointAbove = utils.pointIsAboveLine(ballDest, C.OutfieldWall)
if isPastOutfieldWall then
if not isFlyBall then
-- Grounder at the wall!
ballDest.y = nearbyPointAbove.y - 8
else
-- Home run!
playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted
if utils.distanceBetweenPoints(ball, ballDest) < 2 then
self.announcer:say("HOME RUN!")
self.state.offenseState = C.Offense.homeRun
-- Linger on the home-run ball for a moment, before panning to the bases.
playdate.timer.new(1000, function()
self.panner:panTo(utils.xy(0, 0), function()
return self:pitcherIsReady()
end)
end)
end
end)
end
end
local ballHeightAnimator = isFlyBall
and gfx.animator.new(flyTimeMs, C.GloveZ, 10 + (2 * mult * mult * 0.5), utils.hitEasingHill)
or gfx.animator.new(flyTimeMs, 2 * (mult * mult), 0, utils.createBouncer(4))
ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, ballHeightAnimator, true)
self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ball, ballDest)
end
---@param appliedSpeed number | fun(runner: Runner): number
---@param forcedOnly boolean
---@param isAutoRun boolean
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun)
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun, self.state.deltaSeconds)
if runnersScored ~= 0 then
self:score(runnersScored)
end
return runnersStillMoving, secondsSinceLastRunnerMove
end
function Game:returnToPitcher()
self.fielding:resetFielderPositions()
if self:pitcherIsReady() then
return -- Don't then!
end
actionQueue:newOnly("returnToPitcher", 60 * 1000, function()
while not self:pitcherIsOnTheMound() do
coroutine.yield()
end
if not self.baserunning.batter then
self.baserunning:pushNewBatter()
end
self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
end)
end
---@param defenseHandler InputHandler
function Game:updatePitching(defenseHandler)
pitchTracker:recordIfPassed(self.state.ball)
if self:pitcherIsOnTheMound() then
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
end
if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
self:saveToFile()
local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
if outcome == PitchOutcomes.StrikeOut then
self:strikeOut()
elseif outcome == PitchOutcomes.Walk then
self:walk()
end
self:returnToPitcher()
self.state.pitchIsOver = true
self.state.didSwing = false
end
if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
self:pitch(defenseHandler:pitch())
end
end
function Game:updateGameState()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
self.state.deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime()
self.state.ball:updatePosition()
local fielderHoldingBall, caughtAFlyBall =
self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if caughtAFlyBall then
local fliedOut = self.baserunning:getNewestRunner()
self.baserunning:outRunner(fliedOut, "Fly out!")
self.state.offenseState = C.Offense.fliedOut
self.baserunning:pushNewBatter()
pitchTracker.secondsSinceLastPitch = -1
end
local offenseHandler, defenseHandler = self:currentInputHandlers()
if self.state.offenseState == C.Offense.batting then
self:updatePitching(defenseHandler)
self:updateBatting(offenseHandler)
-- Walk batter to the plate
self.baserunning:updateRunner(self.baserunning.batter, nil, 0, false, self.state.deltaSeconds)
elseif self.state.offenseState == C.Offense.running then
local appliedSpeed = function(runner)
return offenseHandler:runningSpeed(runner, self.state.ball)
end
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false)
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher
self.state.offenseState = C.Offense.batting
self:returnToPitcher()
end
local outedSomeRunner = false
if fielderHoldingBall then
outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
end
defenseHandler:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true, true) then
self.state.offenseState = C.Offense.batting
end
elseif self.state.offenseState == C.Offense.homeRun then
self:updateNonBatterRunners(C.WalkedRunnerSpeed * 2, false, true)
if #self.baserunning.runners == 0 then
-- Give the player a moment to enjoy their home run.
playdate.timer.new(1500, function()
self:returnToPitcher()
actionQueue:upsert("waitForPitcherToHaveBall", 10000, function()
while not self:pitcherIsReady() do
coroutine.yield()
end
self.state.offenseState = C.Offense.batting
end)
end)
end
end
self.baserunning:walkAwayOutRunners(self.state.deltaSeconds)
actionQueue:runWaiting(self.state.deltaSeconds)
end
function Game:update()
self:updateGameState()
gfx.clear()
gfx.setColor(gfx.kColorBlack)
local state = self.state
local offsetX, offsetY = self.panner:get(state.deltaSeconds)
gfx.setDrawOffset(offsetX, offsetY)
fans.draw()
GrassBackground:draw(-400, -720)
local ballHeldBy =
self.characters:drawAll(self.fielding, self.baserunning, self.batting.state, state.battingTeam, state.ball)
if self:userIsOn("defense") then
throwMeter:drawNearFielder(ballHeldBy)
end
if not ballHeldBy then
state.ball:draw()
end
gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 20 then
drawMinimap(self.baserunning.runners, self.fielding.fielders)
end
drawScoreboard(0, C.Screen.H * 0.77, state.stats, self.baserunning.outs, state.battingTeam, state.inning)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
self.announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then
-- luacheck: ignore
playdate.ui.crankIndicator:draw()
end
end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return Game
end
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
MainMenu.start(Game)