-- 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 EasingFunc fun(number, number, number, number): number --- @alias LaunchBall 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 'draw/player.lua' import 'draw/overlay.lua' import 'draw/fielder.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 -- selene: allow(shadowing) local gfx , C = playdate.graphics, C local announcer = Announcer.new() local fielding = Fielding.new() -- TODO: Find a way to get baserunning and npc instantiated closer to the top, here. -- Currently difficult because they depend on nextHalfInning/each other. ------------------ -- GLOBAL STATE -- ------------------ local deltaSeconds = 0 local ball = Ball.new(gfx.animator) local batBase = utils.xy(C.Center.x - 34, 215) local batTip = utils.xy(0, 0) local batAngleDeg = C.CrankOffsetDeg local catcherThrownBall = false ---@alias Team { score: number, benchPosition: XyPair } ---@type table local teams = { home = { score = 0, benchPosition = utils.xy(C.Screen.W + 10, C.Center.y), }, away = { score = 0, benchPosition = utils.xy(-10, C.Center.y), }, } local inning = 1 local battingTeam = teams.away local offenseState = C.Offense.batting -- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0 local secondsSinceLastRunnerMove = 0 local secondsSincePitchAllowed = 0 -- 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. local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper local battingTeamSprites = AwayTeamSprites local fieldingTeamSprites = HomeTeamSprites ------------------------- -- END OF GLOBAL STATE -- ------------------------- local UserTeam = teams.away ---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } ---@type Pitch[] local Pitches = { -- 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 userIsOnSide, boolean userIsOnOtherSide local function userIsOn(side) if UserTeam == nil then -- Both teams are NPC-driven return false, false end local ret if UserTeam == battingTeam then ret = side == C.Sides.offense else ret = side == C.Sides.defense end return ret, not ret end ---@type LaunchBall local function launchBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) throwMeter:reset() ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) 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 launchBall() 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 local function nextHalfInning() pitchTracker:reset() 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 fielding:celebrate() secondsSinceLastRunnerMove = -7 fielding:benchTo(currentlyFieldingTeam.benchPosition) announcer:say("SWITCHING SIDES...") end if gameOver then announcer:say("AND THAT'S THE BALL GAME!") else fielding:resetFielderPositions() if battingTeam == teams.home then inning = inning + 1 end battingTeam = currentlyFieldingTeam playdate.timer.new(2000, function() if battingTeam == teams.home then battingTeamSprites = HomeTeamSprites runnerBlipper = HomeTeamBlipper fieldingTeamSprites = AwayTeamSprites else battingTeamSprites = AwayTeamSprites fieldingTeamSprites = HomeTeamSprites runnerBlipper = AwayTeamBlipper end end) end end local baserunning = Baserunning.new(announcer, nextHalfInning) local npc = Npc.new(baserunning.runners, fielding.fielders) ---@param scoredRunCount number local function score(scoredRunCount) battingTeam.score = battingTeam.score + scoredRunCount announcer:say("SCORE!") 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:reset() fielding:userThrowTo(targetBase, launchBall, throwFlyMs) secondsSinceLastRunnerMove = 0 offenseState = C.Offense.running return true end local function nextBatter() secondsSincePitchAllowed = -3 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 baserunning: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 + C.BatterHandPos.x) or 0 batBase.y = baserunning.batter and (baserunning.batter.y + C.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 -- Hit! 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) pitchTracker:reset() local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) launchBall(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 announcer:say("Foul ball!") pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2) -- TODO: Have a fielder chase for the fly-out return end 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:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) if runnersScored ~= 0 then score(runnersScored) end return runnerMoved end local function userPitch(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 userOnOffense, userOnDefense = userIsOn(C.Sides.offense) if userOnDefense then throwMeter:applyCharge(deltaSeconds, crankLimited) 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.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds end if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then local outcome = pitchTracker:updatePitchCounts() if outcome == PitchOutcomes.StrikeOut then strikeOut() elseif outcome == PitchOutcomes.Walk then walk() end -- Catcher has the ball. Throw it back to the pitcher launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) catcherThrownBall = true end local batSpeed if userOnOffense 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 userOnDefense then local throwFly = throwMeter:readThrow() if throwFly and not buttonControlledThrow(throwFly, true) then userPitch(throwFly) end else pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches)) end end elseif offenseState == C.Offense.running then local appliedSpeed = userOnOffense and crankLimited or npc:runningSpeed(ball) if updateNonBatterRunners(appliedSpeed) then secondsSinceLastRunnerMove = 0 else secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then -- End of play. Throw the ball back to the pitcher launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) fielding:resetFielderPositions() offenseState = C.Offense.batting -- TODO: Remove, or replace with nextBatter() 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 userOnDefense then local throwFly = throwMeter:readThrow() if throwFly then buttonControlledThrow(throwFly) end end if fielderHoldingBall then local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) if not userOnDefense then npc:fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, launchBall) 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 ballIsHeld = fielding:drawFielders(fieldingTeamSprites, ball) if offenseState == C.Offense.batting then gfx.setLineWidth(5) gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y) end local playerHeightOffset = 10 -- 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 battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset) else battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset) end else -- TODO? Change blip speed depending on runner speed? runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset) end end for _, runner in pairs(baserunning.outRunners) do 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(baserunning.runners, fielding.fielders) end drawScoreboard(0, C.Screen.H * 0.77, teams, baserunning.outs, battingTeam, inning) drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes) announcer:draw(C.Center.x, 10) if playdate.isCrankDocked() then playdate.ui.crankIndicator:draw() end 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() launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false) end) BootTune:play() BootTune:setFinishCallback(function() TinnyBackground:play() end) end init()