-- 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 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 'draw/box-score.lua' import 'draw/fielder.lua' import 'draw/overlay.lua' import 'draw/player.lua' import 'draw/transitions.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' -- stylua: ignore end -- TODO: Customizable field structure. E.g. stands and ads etc. 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 BoxScoreData table ---@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 awayTeamSprites SpriteCollection ---@field homeTeamSprites SpriteCollection ---@class MutableState ---@field deltaSeconds number ---@field ball Ball ---@field battingTeam TeamId ---@field catcherThrownBall boolean ---@field offenseState OffenseState ---@field inning number ---@field boxScore BoxScoreData ---@field batBase XyPair ---@field batTip XyPair ---@field batAngleDeg number -- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0 ---@field secondsSinceLastRunnerMove number ---@field secondsSincePitchAllowed 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 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 = nil -- "away" local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat) local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat) 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, 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, catcherThrownBall = false, secondsSinceLastRunnerMove = 0, secondsSincePitchAllowed = 0, battingTeamSprites = settings.awayTeamSprites, fieldingTeamSprites = settings.homeTeamSprites, runnerBlipper = runnerBlipper, boxScore = { home = { 0 }, away = { 0 }, }, }, }, { __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() ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false) end) BootTune:play() BootTune:setFinishCallback(function() TinnyBackground:play() end) return o end ---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ---@alias Pitch fun(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } ---@type Pitch[] local Pitches = { -- Fastball function() return { 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), } end, -- Curve ball function() return { 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), } end, -- Slider function() return { 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), } end, -- Wobbbleball function(ball) return { 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), } 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 function Game:pitch(pitchFlyTimeMs, pitchTypeIndex) Fielding.markIneligible(self.fielding.fielders.pitcher) self.state.ball.heldBy = nil self.state.catcherThrownBall = false self.state.offenseState = C.Offense.batting local current = Pitches[pitchTypeIndex](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 self.state.secondsSincePitchAllowed = 0 end function Game:nextHalfInning() pitchTracker:reset() local homeScore, awayScore = utils.totalScores(self.state.boxScore) local gameOver = self.state.battingTeam == "home" and self.state.inning == self.settings.finalInning and awayScore ~= homeScore if gameOver then self.announcer:say("AND THAT'S THE BALL GAME!") playdate.timer.new(3000, function() transitionTo(BoxScore.new(self.state.boxScore)) end) return end Fielding.celebrate() self.state.secondsSinceLastRunnerMove = -7 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.boxScore.home[self.state.inning] = 0 self.state.boxScore.away[self.state.inning] = 0 end self.state.battingTeam = getOppositeTeamId(self.state.battingTeam) playdate.timer.new(2000, function() if self.state.battingTeam == "home" then self.state.battingTeamSprites = self.settings.homeTeamSprites self.state.runnerBlipper = self.homeTeamBlipper self.state.fieldingTeamSprites = self.settings.awayTeamSprites else self.state.battingTeamSprites = self.settings.awayTeamSprites self.state.fieldingTeamSprites = self.settings.homeTeamSprites self.state.runnerBlipper = self.awayTeamBlipper end end) end ---@param scoredRunCount number function Game:score(scoredRunCount) local battingTeamBoxScore = self.state.boxScore[self.state.battingTeam] battingTeamBoxScore[self.state.inning] = battingTeamBoxScore[self.state.inning] + scoredRunCount self.announcer:say("SCORE!") 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.state.secondsSinceLastRunnerMove = 0 self.state.offenseState = C.Offense.running return true end function Game:nextBatter() self.state.secondsSincePitchAllowed = -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 --[[@as Runner]], "Strike out!") self:nextBatter() end ---@param batDeg number function Game:updateBatting(batDeg, batSpeed) 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 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 ) and self.state.ball.y < 232 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 ballDestX = self.state.ball.x + (ballVelX * C.BattingPower) local ballDestY = self.state.ball.y + (ballVelY * C.BattingPower) pitchTracker:reset() local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) self.state.ball:launch(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 self.announcer:say("Foul ball!") pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2) -- TODO: Have a fielder chase for the fly-out return end self.baserunning:convertBatterToRunner() self.fielding:haveSomeoneChase(ballDestX, ballDestY) end end ---@param appliedSpeed number | fun(runner: Runner): number ---@return boolean someRunnerMoved function Game:updateNonBatterRunners(appliedSpeed, forcedOnly) local runnerMoved, runnersScored = self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds) if runnersScored ~= 0 then self:score(runnersScored) end return runnerMoved end ---@param throwFly number function Game:userPitch(throwFly) local aPressed = playdate.buttonIsPressed(playdate.kButtonA) local bPressed = playdate.buttonIsPressed(playdate.kButtonB) if not aPressed and not bPressed then self:pitch(throwFly, 1) elseif aPressed and not bPressed then self:pitch(throwFly, 2) elseif not aPressed and bPressed then self:pitch(throwFly, 3) elseif aPressed and bPressed then self:pitch(throwFly, 4) 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") if userOnDefense then throwMeter:applyCharge(self.state.deltaSeconds, crankLimited) end if self.state.offenseState == C.Offense.batting then if self.state.ball.y < C.StrikeZoneStartY then pitchTracker.recordedPitchX = nil elseif not pitchTracker.recordedPitchX then pitchTracker.recordedPitchX = self.state.ball.x end local pitcher = self.fielding.fielders.pitcher if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then self.state.secondsSincePitchAllowed = self.state.secondsSincePitchAllowed + self.state.deltaSeconds end if self.state.secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not self.state.catcherThrownBall then local outcome = pitchTracker:updatePitchCounts() if outcome == PitchOutcomes.StrikeOut then self:strikeOut() elseif outcome == PitchOutcomes.Walk then self:walk() end -- Catcher has the ball. Throw it back to the pitcher self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) self.state.catcherThrownBall = true 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.catcherThrownBall, 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, crankLimited, self.state.deltaSeconds) if self.state.secondsSincePitchAllowed > C.PitchAfterSeconds then if userOnDefense then local throwFly = throwMeter:readThrow() if throwFly and not self:buttonControlledThrow(throwFly, true) then self:userPitch(throwFly) 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 if self:updateNonBatterRunners(appliedSpeed) then self.state.secondsSinceLastRunnerMove = 0 else self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then -- End of play. Throw the ball back to the pitcher self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly. self.fielding:markAllIneligible() self.fielding:resetFielderPositions() self.state.offenseState = C.Offense.batting -- TODO: Remove, or replace with nextBatter() if not self.baserunning.batter then self.baserunning:pushNewBatter() end end end elseif self.state.offenseState == C.Offense.walking then if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then self.state.offenseState = C.Offense.batting end end local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) if userOnDefense then local throwFly = throwMeter:readThrow() if throwFly then self:buttonControlledThrow(throwFly) end end 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 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 = getDrawOffset(ball.x, ball.y) gfx.setDrawOffset(offsetX, offsetY) GrassBackground:draw(-400, -240) ---@type { y: number, drawAction: fun() }[] local characterDraws = {} function addDraw(y, drawAction) characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction } end local danceOffset = FielderDanceAnimator:currentValue() local ballIsHeld = false for _, fielder in pairs(self.fielding.fielders) do addDraw(fielder.y + danceOffset, function() ballIsHeld = drawFielder( self.state.fieldingTeamSprites, self.state.ball, fielder.x, fielder.y + danceOffset ) or ballIsHeld end) end local playerHeightOffset = 20 -- TODO? Scale sprites down as y increases 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.back:draw(runner.x, runner.y - playerHeightOffset) else self.state.battingTeamSprites.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) 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(5) gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y) end for _, runner in pairs(self.baserunning.outRunners) do self.state.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(self.baserunning.runners, self.fielding.fielders) end local homeScore, awayScore = utils.totalScores(self.state.boxScore) 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)