From 881ff0e734c2a164f3a4209b8ff629fb2b8786a3 Mon Sep 17 00:00:00 2001 From: Sage Vaillancourt Date: Fri, 7 Feb 2025 20:29:40 -0500 Subject: [PATCH] Implementing walks and strike-outs * Look at limiting batting/throw power with math.log() * Extract draw/fielder.lua * Extract some values into constants.lua * Extract npc.lua for computer batting (and eventually probably more CPU behavior) * pitchTracker in utils --- Makefile | 2 +- src/announcer.lua | 4 +- src/constants.lua | 13 +++ src/draw/fielder.lua | 29 ++++++ src/main.lua | 240 ++++++++++++++++++------------------------- src/npc.lua | 35 +++++++ src/scoreboard.lua | 5 +- src/utils.lua | 43 +++++++- 8 files changed, 226 insertions(+), 145 deletions(-) create mode 100644 src/constants.lua create mode 100644 src/draw/fielder.lua create mode 100644 src/npc.lua diff --git a/Makefile b/Makefile index 80e414c..7ab9abb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -SOURCE_FILES := src/utils.lua src/dbg.lua src/announcer.lua src/graphics.lua src/scoreboard.lua src/main.lua +SOURCE_FILES := src/constants.lua src/draw/* src/utils.lua src/dbg.lua src/npc.lua src/announcer.lua src/graphics.lua src/scoreboard.lua src/main.lua all: pdc src BatterUp.pdx diff --git a/src/announcer.lua b/src/announcer.lua index e7ce4f2..e429292 100644 --- a/src/announcer.lua +++ b/src/announcer.lua @@ -1,3 +1,6 @@ +-- selene: allow(shadowing) +local gfx = playdate.graphics + local AnnouncementFont = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft") local AnnouncementTransitionMs = 300 local AnnouncerMarginX = 26 @@ -49,7 +52,6 @@ function announcer.draw(self, x, y) end x = x - 5 -- Infield center is slightly offset from screen center - local gfx = playdate.graphics local originalDrawMode = gfx.getImageDrawMode() local width = math.max(150, (AnnouncerMarginX * 2) + AnnouncementFont:getTextWidth(self.textQueue[1])) local animY = self.animatorY:currentValue() diff --git a/src/constants.lua b/src/constants.lua new file mode 100644 index 0000000..663edeb --- /dev/null +++ b/src/constants.lua @@ -0,0 +1,13 @@ +-- selene: allow(unscoped_variables) +C = {} + +C.Screen = { + W = playdate.display.getWidth(), + H = playdate.display.getHeight(), +} + +C.Center = utils.xy(C.Screen.W / 2, C.Screen.H / 2) + +C.StrikeZoneStartX = C.Center.x - 16 +C.StrikeZoneEndX = C.StrikeZoneStartX + 24 +C.StrikeZoneStartY = C.Screen.H - 35 diff --git a/src/draw/fielder.lua b/src/draw/fielder.lua new file mode 100644 index 0000000..21f8199 --- /dev/null +++ b/src/draw/fielder.lua @@ -0,0 +1,29 @@ +-- selene: allow(shadowing) +local gfx = playdate.graphics + +local Glove = playdate.graphics.image.new("images/game/glove.png") --[[@as pd_image]] +local GloveSizeX, GloveSizeY = Glove:getSize() + +local GloveHoldingBall = playdate.graphics.image.new("images/game/glove-holding-ball.png") --[[@as pd_image]] +local GloveOffX, GloveOffY = GloveSizeX / 2, GloveSizeY / 2 + +---@param fielderX number +---@param fielderY number +---@return boolean isHoldingBall +local function drawFielderGlove(ball, fielderX, fielderY) + local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z) + local shoulderX, shoulderY = fielderX + 10, fielderY + 5 + if distanceFromBall > 20 then + Glove:draw(shoulderX, shoulderY) + return false + else + GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY) + return true + end +end + +---@return boolean isHoldingBall +function drawFielder(ball, x, y) + gfx.fillRect(x, y, 14, 25) + return drawFielderGlove(ball, x, y) +end diff --git a/src/main.lua b/src/main.lua index 0447c1c..0429e4a 100644 --- a/src/main.lua +++ b/src/main.lua @@ -34,22 +34,20 @@ import 'CoreLibs/ui.lua' --- @alias EasingFunc fun(number, number, number, number): number +import 'utils.lua' +import 'constants.lua' + import 'announcer.lua' import 'dbg.lua' import 'graphics.lua' +import 'npc.lua' import 'scoreboard.lua' -import 'utils.lua' +import 'draw/fielder' -- stylua: ignore end +-- selene: allow(shadowing) local gfx = playdate.graphics -local Screen = { - W = playdate.display.getWidth(), - H = playdate.display.getHeight(), -} - -local Center = utils.xy(Screen.W / 2, Screen.H / 2) - local BootTune = playdate.sound.sampleplayer.new("sounds/boot-tune.wav") -- local BootTune = playdate.sound.sampleplayer.new("sounds/boot-tune-organy.wav") local TinnyBackground = playdate.sound.sampleplayer.new("sounds/tinny-background.wav") @@ -60,11 +58,6 @@ local PlayerFrown = gfx.image.new("images/game/player-frown.png") --[[@a local PlayerSmile = gfx.image.new("images/game/player.png") --[[@as pd_image]] local PlayerBack = gfx.image.new("images/game/player-back.png") --[[@as pd_image]] -local Glove = gfx.image.new("images/game/glove.png") --[[@as pd_image]] -local GloveHoldingBall = gfx.image.new("images/game/glove-holding-ball.png") --[[@as pd_image]] -local GloveSizeX, GloveSizeY = Glove:getSize() -local GloveOffX, GloveOffY = GloveSizeX / 2, GloveSizeY / 2 - local PlayerImageBlipper = blipper.new(100, "images/game/player.png", "images/game/player-lowhat.png") local DanceBounceMs = 500 @@ -122,7 +115,7 @@ local Pitches = { local CrankOffsetDeg = 90 local BatOffset = utils.xy(10, 25) -local batBase = utils.xy(Center.x - 34, 215) +local batBase = utils.xy(C.Center.x - 34, 215) local batTip = utils.xy(0, 0) local TagDistance = 15 @@ -130,8 +123,9 @@ local TagDistance = 15 local SmallestBallRadius = 6 local ball = { - x = Center.x --[[@as number]], - y = Center.y --[[@as number]], + x = C.Center.x --[[@as number]], + y = C.Center.y --[[@as number]], + z = 0, size = SmallestBallRadius, heldBy = nil --[[@type Runner | nil]], } @@ -141,6 +135,7 @@ local BatLength = 50 local Offense = { batting = {}, running = {}, + walking = {}, } local Sides = { @@ -156,11 +151,11 @@ local offenseMode = Offense.batting local teams = { home = { score = 0, - benchPosition = utils.xy(Screen.W + 10, Center.y), + benchPosition = utils.xy(C.Screen.W + 10, C.Center.y), }, away = { score = 0, - benchPosition = utils.xy(-10, Center.y), + benchPosition = utils.xy(-10, C.Center.y), }, } @@ -194,10 +189,10 @@ local First , Second , Third , Home = 1, 2, 3, 4 ---@type Base[] local Bases = { - utils.xy(Screen.W * 0.93, Screen.H * 0.52), - utils.xy(Screen.W * 0.47, Screen.H * 0.19), - utils.xy(Screen.W * 0.03, Screen.H * 0.52), - utils.xy(Screen.W * 0.474, Screen.H * 0.79), + utils.xy(C.Screen.W * 0.93, C.Screen.H * 0.52), + utils.xy(C.Screen.W * 0.47, C.Screen.H * 0.19), + utils.xy(C.Screen.W * 0.03, C.Screen.H * 0.52), + utils.xy(C.Screen.W * 0.474, C.Screen.H * 0.79), } -- Pseudo-base for batter to target @@ -230,13 +225,13 @@ local fielders = { pitcher = newFielder("Pitcher", 30), catcher = newFielder("Catcher", 35), left = newFielder("Left", 40), - center = newFielder("Center", 40), + center = newFielder("C.Center", 40), right = newFielder("Right", 40), } local PitcherStartPos = { - x = Screen.W * 0.48, - y = Screen.H * 0.40, + x = C.Screen.W * 0.48, + y = C.Screen.H * 0.40, } --- Actually only benches the infield, because outfielders are far away! @@ -260,15 +255,15 @@ function resetFielderPositions(fromOffTheField) end end - fielders.first.target = utils.xy(Screen.W - 65, Screen.H * 0.48) - fielders.second.target = utils.xy(Screen.W * 0.70, Screen.H * 0.30) - fielders.shortstop.target = utils.xy(Screen.W * 0.30, Screen.H * 0.30) - fielders.third.target = utils.xy(Screen.W * 0.1, Screen.H * 0.48) + fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) + fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) + fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) + fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) fielders.pitcher.target = utils.xy(PitcherStartPos.x, PitcherStartPos.y) - fielders.catcher.target = utils.xy(Screen.W * 0.475, Screen.H * 0.92) - fielders.left.target = utils.xy(Screen.W * -1, Screen.H * -0.2) - fielders.center.target = utils.xy(Center.x, Screen.H * -0.4) - fielders.right.target = utils.xy(Screen.W * 2, fielders.left.target.y) + fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) + fielders.left.target = utils.xy(C.Screen.W * -1, C.Screen.H * -0.2) + fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4) + fielders.right.target = utils.xy(C.Screen.W * 2, fielders.left.target.y) end local BatterStartingX = Bases[Home].x - 40 @@ -409,15 +404,22 @@ local ResetFieldersAfterSeconds = 5 -- TODO: Replace with a timer, repeatedly reset, instead of setting to 0 local secondsSinceLastRunnerMove = 0 ----@param runnerIndex integer -function outRunner(runnerIndex) - outRunners[#outRunners + 1] = runners[runnerIndex] - table.remove(runners, runnerIndex) +---@param runner integer | Runner +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 + outRunners[#outRunners + 1] = runners[runner] + table.remove(runners, runner) outs = outs + 1 updateForcedRunners() - announcer:say("YOU'RE OUT!") + announcer:say(message or "YOU'RE OUT!") if outs == 3 then local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home local gameOver = inning == 9 and teams.away.score ~= teams.home.score @@ -437,6 +439,7 @@ function outRunner(runnerIndex) if gameOver then announcer:say("AND THAT'S THE BALL GAME!") else + resetFielderPositions() if battingTeam == teams.home then inning = inning + 1 end @@ -665,53 +668,34 @@ end ---@type number local batAngleDeg --- Used for tracking whether or not a pitch was a strike -local recordedPitchX = nil -local balls = 0 -local strikes = 0 - -local StrikeZoneStartX = Center.x - 16 -local StrikeZoneEndX = StrikeZoneStartX + 24 -local StrikeZoneStartY = Screen.H - 35 - -function recordStrikePosition() - if not recordedPitchX and ball.y > StrikeZoneStartY then - recordedPitchX = ball.x - end -end - function nextBatter() + batter = nil playdate.timer.new(2000, function() - balls = 0 - strikes = 0 - batter = newRunner() + pitchTracker:reset() + if not batter then + batter = newRunner() + end end) end function walk() - -- TODO + announcer:say("Walk!") + fielders.first.target = Bases[First] + batter.nextBase = Bases[First] + batter.prevBase = Bases[Home] + offenseMode = Offense.walking + batter = nil + updateForcedRunners() nextBatter() end function strikeOut() - -- TODO + local outBatter = batter + batter = nil + outRunner(outBatter --[[@as Runner]], "Strike out!") nextBatter() end -function recordPitch() - if recordedPitchX > StrikeZoneStartX and recordedPitchX < StrikeZoneEndX then - strikes = strikes + 1 - if strikes >= 3 then - strikeOut() - end - else - balls = balls + 1 - if balls >= 4 then - walk() - end - end -end - ---@param batDeg number function updateBatting(batDeg, batSpeed) if ball.y < BallOffscreen then @@ -728,7 +712,7 @@ function updateBatting(batDeg, batSpeed) if batSpeed > 0 - and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H) + and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H) and ball.y < 232 --not isTouchingBall(fielders.catcher.x, fielders.catcher.y) then BatCrackSound:play() @@ -770,14 +754,14 @@ end --- Returns true only if at least one of the given runners moved during this update ---@param appliedSpeed number ---@return boolean -function updateRunning(appliedSpeed) +function updateRunning(appliedSpeed, forcedOnly) ball.size = ballSizeAnimator:currentValue() 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 then + if runner ~= batter and (not forcedOnly or runner.forcedTo) then runnerMoved = updateRunner(runner, runnerIndex, appliedSpeed) or runnerMoved end end @@ -787,7 +771,7 @@ end function walkAwayOutRunners() for i, runner in ipairs(outRunners) do - if runner.x < Screen.W + 50 and runner.y < Screen.H + 50 then + 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 @@ -810,76 +794,66 @@ function playerPitch(throwFly) end end -local npcBatDeg = 0 -local BaseNpcBatSpeed = 1500 -local npcBatSpeed = 1500 - -function npcBatAngle() - if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < Center.x + 15) then - npcBatDeg = npcBatDeg + (deltaSeconds * npcBatSpeed) - else - npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed - npcBatDeg = 200 - end - return npcBatDeg -end - -function npcBatChange() - return deltaSeconds * npcBatSpeed -end - -function npcRunningSpeed() - if #runners == 0 then - return 0 - end - local touchedBase = isTouchingBase(runners[1].x, runners[1].y) - if not touchedBase or touchedBase == Bases[Home] then - return 10 - end - return 0 -end - function updateGameState() deltaSeconds = playdate.getElapsedTime() or 0 playdate.resetElapsedTime() - local crankChange = playdate.getCrankChange() --[[@as number, number]] + local crankChange = playdate.getCrankChange() --[[@as number]] + local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * 10) + if crankChange < 0 then + crankLimited = crankLimited * -1 + end if ball.heldBy then ball.x = ball.heldBy.x ball.y = ball.heldBy.y else ball.x = ballAnimatorX:currentValue() - ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue() + ball.z = ballFloatAnimator:currentValue() + ball.y = ballAnimatorY:currentValue() + ball.z end local playerOnOffense, playerOnDefense = playerIsOn(Sides.offense) if playerOnDefense then throwMeter = math.max(0, throwMeter - (deltaSeconds * 150)) - throwMeter = throwMeter + math.abs(crankChange) + throwMeter = throwMeter + math.abs(crankLimited) end if offenseMode == Offense.batting then - recordStrikePosition() - secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds + if ball.y < C.StrikeZoneStartY then + pitchTracker.recordedPitchX = nil + elseif not pitchTracker.recordedPitchX then + pitchTracker.recordedPitchX = ball.x + end + + if utils.distanceBetween(fielders.pitcher.x, fielders.pitcher.y, PitchStartX, PitchStartY) < BaseHitbox then + secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds + end if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then - recordPitch() + local outcome = pitchTracker:updatePitchCounts() + if outcome == PitchOutcomes.StrikeOut then + strikeOut() + elseif outcome == PitchOutcomes.Walk then + walk() + end throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true) catcherThrownBall = true end local batSpeed if playerOnOffense then - batAngleDeg, batSpeed = (playdate.getCrankPosition() + CrankOffsetDeg) % 360, crankChange + batAngleDeg = (playdate.getCrankPosition() + CrankOffsetDeg) % 360 + batSpeed = crankLimited else - batAngleDeg, batSpeed = npcBatAngle(), npcBatChange() + 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, crankChange) + updateRunner(batter, nil, crankLimited) if secondsSincePitchAllowed > PitchAfterSeconds then if playerOnDefense then @@ -892,7 +866,7 @@ function updateGameState() end end elseif offenseMode == Offense.running then - local appliedSpeed = playerOnOffense and crankChange or npcRunningSpeed() + local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(Bases, runners) if updateRunning(appliedSpeed) then secondsSinceLastRunnerMove = 0 else @@ -906,6 +880,11 @@ function updateGameState() end end end + elseif offenseMode == Offense.walking then + updateForcedRunners() + if not updateRunning(10, true) then + offenseMode = Offense.batting + end end for _, fielder in pairs(fielders) do @@ -915,11 +894,11 @@ function updateGameState() end local MinimapSizeX, MinimapSizeY = Minimap:getSize() -local MinimapPosX, MinimapPosY = Screen.W - MinimapSizeX, Screen.H - MinimapSizeY +local MinimapPosX, MinimapPosY = C.Screen.W - MinimapSizeX, C.Screen.H - MinimapSizeY local FieldHeight = Bases[Home].y - Bases[Second].y -local MinimapMultX = 0.75 * MinimapSizeX / Screen.W +local MinimapMultX = 0.75 * MinimapSizeX / C.Screen.W local MinimapOffsetX = MinimapPosX + 5 local MinimapMultY = 0.70 * MinimapSizeY / FieldHeight local MinimapOffsetY = MinimapPosY - 15 @@ -934,21 +913,6 @@ function drawMinimap() end end ----@param fielder Fielder ----@return boolean isHoldingBall -function drawFielderGlove(fielder) - local distanceFromBall = - utils.distanceBetweenZ(fielder.x, fielder.y, 0, ball.x, ball.y, ballFloatAnimator:currentValue()) - local shoulderX, shoulderY = fielder.x + 10, fielder.y + FielderDanceAnimator:currentValue() + 5 - if distanceFromBall > 20 then - Glove:draw(shoulderX, shoulderY) - return false - else - GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY) - return true - end -end - function playdate.update() playdate.timer.updateTimers() gfx.animation.blinker.updateAll() @@ -959,7 +923,7 @@ function playdate.update() local offsetX, offsetY = 0, 0 if ball.x < BallOffscreen then - offsetX, offsetY = getDrawOffset(Screen.W, Screen.H, ball.x, ball.y) + offsetX, offsetY = getDrawOffset(C.Screen.W, C.Screen.H, ball.x, ball.y) gfx.setDrawOffset(offsetX, offsetY) end @@ -968,9 +932,7 @@ function playdate.update() local fielderDanceHeight = FielderDanceAnimator:currentValue() local ballIsHeld = false for _, fielder in pairs(fielders) do - local fielderY = fielder.y + fielderDanceHeight - gfx.fillRect(fielder.x, fielderY, 14, 25) - ballIsHeld = drawFielderGlove(fielder) or ballIsHeld + ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld end if offenseMode == Offense.batting then @@ -1013,9 +975,9 @@ function playdate.update() if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then drawMinimap() end - drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning) - drawBallsAndStrikes(290, Screen.H - 20, balls, strikes) - announcer:draw(Center.x, 10) + 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 function init() diff --git a/src/npc.lua b/src/npc.lua new file mode 100644 index 0000000..cb0f958 --- /dev/null +++ b/src/npc.lua @@ -0,0 +1,35 @@ +local npcBatDeg = 0 +local BaseNpcBatSpeed = 1500 +local npcBatSpeed = 1500 + +-- selene: allow(unscoped_variables) +npc = {} + +function npc.updateBatAngle(ball, catcherThrownBall, deltaSec) + if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then + npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed) + else + npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed + npcBatDeg = 200 + end + return npcBatDeg +end + +function npc.batSpeed() + return npcBatSpeed +end + +function npc.runningSpeed(baseConstants, runners) + if #runners == 0 then + return 0 + end + local touchedBase = isTouchingBase(runners[1].x, runners[1].y) + if not touchedBase or touchedBase == baseConstants[4] then + return 10 + end + return 0 +end + +if not playdate then + return utils +end diff --git a/src/scoreboard.lua b/src/scoreboard.lua index c4827e9..58e7336 100644 --- a/src/scoreboard.lua +++ b/src/scoreboard.lua @@ -1,3 +1,6 @@ +-- selene: allow(shadowing) +local gfx = playdate.graphics + local ScoreFont = playdate.graphics.font.new("fonts/font-full-circle.pft") local OutBubbleRadius = 5 local ScoreboardMarginX = 6 @@ -25,7 +28,6 @@ function drawBallsAndStrikes(x, y, balls, strikes) return end - local gfx = playdate.graphics gfx.setColor(gfx.kColorBlack) gfx.fillRect(x, y, BallStrikeWidth, BallStrikeHeight) local originalDrawMode = gfx.getImageDrawMode() @@ -39,7 +41,6 @@ function drawBallsAndStrikes(x, y, balls, strikes) end function drawScoreboard(x, y, teams, outs, battingTeam, inning) - local gfx = playdate.graphics local homeScore = teams.home.score local awayScore = teams.away.score diff --git a/src/utils.lua b/src/utils.lua index 81cd5c1..5a352ab 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -142,8 +142,47 @@ function utils.getNearestOf(array, x, y, extraCondition) return nearest, nearestDistance end ----@alias Cache { get: fun(key: `Key`): `Value` } +-- selene: allow(unscoped_variables) +PitchOutcomes = { + StrikeOut = {}, + Walk = {}, +} + +-- selene: allow(unscoped_variables) +pitchTracker = { + --- Position of the pitch, or nil, if one has not been recorded. + ---@type number | nil + recordedPitchX = nil, + + strikes = 0, + balls = 0, + + reset = function(self) + self.strikes = 0 + self.balls = 0 + end, + + updatePitchCounts = function(self) + if not self.recordedPitchX then + return + end + + if self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then + self.strikes = self.strikes + 1 + if self.strikes >= 3 then + self:reset() + return PitchOutcomes.StrikeOut + end + else + self.balls = self.balls + 1 + if self.balls >= 4 then + self:reset() + return PitchOutcomes.Walk + end + end + end, +} if not playdate then - return utils + return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes } end