-- 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 --- @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 'dbg.lua' import 'fielding.lua' import 'graphics.lua' import 'npc.lua' import 'pitching.lua' import 'draw/box-score.lua' import 'draw/fans.lua' import 'draw/fielder.lua' import 'draw/overlay.lua' import 'draw/panner.lua' import 'draw/player.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 , C = playdate.graphics, C ---@alias Team { benchPosition: XyPair } ---@type table local teams = { 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 ---@field batBase XyPair ---@field batTip XyPair ---@field batAngleDeg number -- 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. ---@field runnerBlipper Blipper ---@field battingTeamSprites SpriteCollection ---@field fieldingTeamSprites SpriteCollection ---@class Game ---@field private settings Settings ---@field private announcer Announcer ---@field private fielding Fielding ---@field private baserunning Baserunning ---@field private npc Npc ---@field private homeTeamBlipper Blipper ---@field private awayTeamBlipper Blipper ---@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 Npc | 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 homeTeamBlipper = blipper.new(100, settings.homeTeamSpriteGroup) local awayTeamBlipper = blipper.new(100, settings.awayTeamSpriteGroup) local battingTeam = "away" local runnerBlipper = battingTeam == "away" and awayTeamBlipper or homeTeamBlipper local ball = Ball.new(gfx.animator) local o = setmetatable({ settings = settings, announcer = announcer, fielding = fielding, homeTeamBlipper = homeTeamBlipper, awayTeamBlipper = awayTeamBlipper, panner = Panner.new(ball), state = state or { batBase = utils.xy(C.Center.x - 34, 215), batTip = utils.xy(0, 0), batAngleDeg = C.CrankOffsetDeg, deltaSeconds = 0, ball = ball, battingTeam = battingTeam, offenseState = C.Offense.batting, inning = 1, pitchIsOver = true, didSwing = false, battingTeamSprites = settings.awayTeamSpriteGroup, fieldingTeamSprites = settings.homeTeamSpriteGroup, runnerBlipper = runnerBlipper, stats = Statistics.new(), }, }, { __index = Game }) o.baserunning = baserunning or Baserunning.new(announcer, function() o:nextHalfInning() end) o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders) o.fielding:resetFielderPositions(teams.home.benchPosition) playdate.timer.new(2000, function() o:returnToPitcher() end) 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 ---@param pitchFlyTimeMs number | nil ---@param pitchTypeIndex number | nil ---@param accuracy number The closer to 1.0, the better function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy) 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 homeScore, awayScore = utils.totalScores(self.state.stats) local isFinalInning = self.state.inning >= self.settings.finalInning local gameOver = isFinalInning and self.state.battingTeam == "home" and awayScore ~= homeScore gameOver = gameOver or self.state.battingTeam == "away" and isFinalInning and homeScore > awayScore Fielding.celebrate() if gameOver 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() 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 self.state.battingTeam = getOppositeTeamId(self.state.battingTeam) playdate.timer.new(2000, function() if self.state.battingTeam == "home" then self.state.battingTeamSprites = self.settings.homeTeamSpriteGroup self.state.runnerBlipper = self.homeTeamBlipper self.state.fieldingTeamSprites = self.settings.awayTeamSpriteGroup else self.state.battingTeamSprites = self.settings.awayTeamSpriteGroup self.state.fieldingTeamSprites = self.settings.homeTeamSpriteGroup self.state.runnerBlipper = self.awayTeamBlipper end 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.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 self.fielding:userThrowTo(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 local SwingBackDeg = 30 local SwingForwardDeg = 170 ---@param batDeg number function Game:updateBatting(batDeg, batSpeed) if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then self.state.didSwing = true end -- TODO? Make the bat angle work more like the throw meter. -- Would instead constantly drift toward a default value, giving us a little more control, -- and letting the user find a crank position and direction that works for them local batAngle = math.rad(batDeg) -- TODO: animate bat-flip or something self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0 self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0 self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle)) self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle)) if batSpeed > 0 and self.state.ball.y < 232 and utils.pointDirectlyUnderLine( self.state.ball.x, self.state.ball.y, self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y, C.Screen.H ) then -- Hit! BatCrackReverb:play() self.state.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 ballDest = utils.xy(self.state.ball.x + (ballVelX * C.BattingPower), self.state.ball.y + (ballVelY * C.BattingPower)) pitchTracker:reset() local flyTimeMs = 2000 -- 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.x, ballDest.y) 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 if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then playdate.timer.new(flyTimeMs, function() -- Verify that the home run wasn't intercepted if utils.within(1, self.state.ball.x, ballDest.x) and utils.within(1, self.state.ball.y, ballDest.y) 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 local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) self.state.ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler) self.baserunning:convertBatterToRunner() self.fielding:haveSomeoneChase(ballDest.x, ballDest.y) end end ---@param appliedSpeed number | fun(runner: Runner): number --- ---@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 throwFly number function Game:userPitch(throwFly, accuracy) local aPressed = playdate.buttonIsPressed(playdate.kButtonA) local bPressed = playdate.buttonIsPressed(playdate.kButtonB) if not aPressed and not bPressed then self:pitch(throwFly, 1, accuracy) elseif aPressed and not bPressed then self:pitch(throwFly, 2, accuracy) elseif not aPressed and bPressed then self:pitch(throwFly, 3, accuracy) elseif aPressed and bPressed then self:pitch(throwFly, 4, accuracy) end end function Game:updateGameState() self.state.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 self.state.ball:updatePosition() local userOnOffense, userOnDefense = self:userIsOn("offense") local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) if fielderHoldingBall then local outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall) if not userOnDefense and self.state.offenseState == C.Offense.running then self.npc:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball) end end if self.state.offenseState == C.Offense.batting then pitchTracker:recordIfPassed(self.state.ball) local pitcher = self.fielding.fielders.pitcher if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds end if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then 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 local batSpeed if userOnOffense then self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 batSpeed = crankLimited else self.state.batAngleDeg = self.npc:updateBatAngle(self.state.ball, self.state.pitchIsOver, self.state.deltaSeconds) batSpeed = self.npc:batSpeed() * self.state.deltaSeconds end self:updateBatting(self.state.batAngleDeg, batSpeed) -- Walk batter to the plate self.baserunning:updateRunner( self.baserunning.batter, nil, userOnOffense and crankLimited or 0, false, self.state.deltaSeconds ) if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then if userOnDefense then local powerRatio, accuracy = throwMeter:readThrow(crankChange) if powerRatio then local throwFly = C.PitchFlyMs / powerRatio if throwFly and not self:buttonControlledThrow(throwFly, true) then self:userPitch(throwFly, accuracy) end end else self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), math.random(#Pitches)) end end elseif self.state.offenseState == C.Offense.running then local appliedSpeed = userOnOffense and crankLimited or function(runner) return self.npc: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 if userOnDefense then local powerRatio = throwMeter:readThrow(crankChange) if powerRatio then local throwFly = C.PitchFlyMs / powerRatio if throwFly then self:buttonControlledThrow(throwFly) end end end 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() playdate.timer.updateTimers() gfx.animation.blinker.updateAll() self:updateGameState() local ball = self.state.ball gfx.clear() gfx.setColor(gfx.kColorBlack) local offsetX, offsetY = self.panner:get(self.state.deltaSeconds) gfx.setDrawOffset(offsetX, offsetY) fans.draw() GrassBackground:draw(-400, -720) ---@type { y: number, drawAction: fun() }[] local characterDraws = {} function addDraw(y, drawAction) characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction } end local danceOffset = FielderDanceAnimator:currentValue() ---@type Fielder | nil local ballHeldBy for _, fielder in pairs(self.fielding.fielders) do addDraw(fielder.y + danceOffset, function() local ballHeldByThisFielder = drawFielder( self.state.fieldingTeamSprites[fielder.spriteIndex], self.state.ball, fielder.x, fielder.y + danceOffset ) if ballHeldByThisFielder then ballHeldBy = fielder end end) end local playerHeightOffset = 20 for _, runner in pairs(self.baserunning.runners) do addDraw(runner.y, function() if runner == self.baserunning.batter then if self.state.batAngleDeg > 50 and self.state.batAngleDeg < 200 then self.state.battingTeamSprites[runner.spriteIndex].back:draw(runner.x, runner.y - playerHeightOffset) else self.state.battingTeamSprites[runner.spriteIndex].smiling:draw( runner.x, runner.y - playerHeightOffset ) end else -- TODO? Change blip speed depending on runner speed? self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset, runner) end end) end table.sort(characterDraws, function(a, b) return a.y < b.y end) for _, character in pairs(characterDraws) do character.drawAction() end if self.state.offenseState == C.Offense.batting then gfx.setLineWidth(7) gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y) gfx.setColor(gfx.kColorWhite) gfx.setLineCapStyle(gfx.kLineCapStyleRound) gfx.setLineWidth(3) gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y) gfx.setColor(gfx.kColorBlack) end for _, runner in pairs(self.baserunning.outRunners) do self.state.battingTeamSprites[runner.spriteIndex].frowning:draw(runner.x, runner.y - playerHeightOffset) end for _, runner in pairs(self.baserunning.scoredRunners) do self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset, runner) end if self:userIsOn("defense") then throwMeter:drawNearFielder(ballHeldBy) end if not ballHeldBy 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(self.baserunning.runners, self.fielding.fielders) end local homeScore, awayScore = utils.totalScores(self.state.stats) drawScoreboard( 0, C.Screen.H * 0.77, homeScore, awayScore, self.baserunning.outs, self.state.battingTeam, self.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 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)