Pan back from home runs

Move secondsSinceLastRunnerMove into Baserunning
Tweak draw offset logic - a little jumpy, but better at following the long ball
Taller GrassBackground
Move secondsSinceLastPitch into pitchTracker
Extract pitchTracker and throwMeter
Slightly more truthful utils.moveAtSpeed()
This commit is contained in:
Sage Vaillancourt 2025-02-19 17:13:06 -05:00
parent aebbc35bac
commit ad82035ccc
9 changed files with 242 additions and 150 deletions

View File

@ -12,6 +12,8 @@
---@field scoredRunners Runner[]
---@field batter Runner | nil
---@field outs number
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
---@field secondsSinceLastRunnerMove number
---@field announcer Announcer
---@field onThirdOut fun()
Baserunning = {}
@ -237,9 +239,9 @@ end
--- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved, number runnersScored
---@return boolean runnersStillMoving, number runnersScored, number secondsSinceLastMove
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
local someRunnerMoved = false
local runnersStillMoving = false
local runnersScored = 0
local speedIsFunction = type(appliedSpeed) == "function"
@ -251,18 +253,21 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon
speed = appliedSpeed(runner)
end
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
someRunnerMoved = someRunnerMoved or thisRunnerMoved
runnersStillMoving = runnersStillMoving or thisRunnerMoved
if thisRunnerScored then
runnersScored = runnersScored + 1
end
end
end
if someRunnerMoved then
if runnersStillMoving then
self.secondsSinceLastRunnerMove = 0
self:updateForcedRunners()
else
self.secondsSinceLastRunnerMove = self.secondsSinceLastRunnerMove + deltaSeconds
end
return someRunnerMoved, runnersScored
return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove
end
-- luacheck: ignore

View File

@ -70,8 +70,8 @@ C.BallOffscreen = 999
C.PitchAfterSeconds = 6
C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050
C.PitchStartX = 195
C.PitchStartY, C.PitchEndY = 105, 240
C.PitchStart = utils.xy(195, 105)
C.PitchEndY = 240
C.DefaultLaunchPower = 4
@ -91,7 +91,7 @@ C.SmallestBallRadius = 6
C.BatLength = 35
---@alias OffenseState "batting" | "running" | "walking"
---@alias OffenseState "batting" | "running" | "walking" | "homeRun"
--- An enum for what state the offense is in
---@type table<string, OffenseState>
C.Offense = {

52
src/draw/panner.lua Normal file
View File

@ -0,0 +1,52 @@
---@class Panner
Panner = {}
local function panCoroutine(ball)
local offset = utils.xy(getDrawOffset(ball.x, ball.y))
while true do
local target, deltaSeconds = coroutine.yield(offset.x, offset.y)
if target == nil then
offset = utils.xy(getDrawOffset(ball.x, ball.y))
else
while utils.moveAtSpeed(offset, 200 * deltaSeconds, target, 20) do
target, deltaSeconds = coroutine.yield(offset.x, offset.y)
end
-- -- Pan back to ball
-- while utils.moveAtSpeed(offset, 200 * deltaSeconds, ball, 20) do
-- target, deltaSeconds = coroutine.yield(offset.x, offset.y)
-- end
end
end
end
---@param ball XyPair
function Panner.new(ball)
return setmetatable({
coroutine = coroutine.create(function()
panCoroutine(ball)
end),
panTarget = nil,
}, { __index = Panner })
end
---@param deltaSeconds number
---@return number offsetX, number offsetY
function Panner:get(deltaSeconds)
if self.holdUntil and self.holdUntil() then
self:reset()
end
local _, offsetX, offsetY = coroutine.resume(self.coroutine, self.panTarget, deltaSeconds)
return offsetX, offsetY
end
---@param panTarget XyPair
---@param holdUntil fun(): boolean
function Panner:panTo(panTarget, holdUntil)
self.panTarget = panTarget
self.holdUntil = holdUntil
end
function Panner:reset()
self.holdUntil = nil
self.panTarget = nil
end

View File

@ -5,8 +5,9 @@
--- @field target XyPair | nil
--- @field speed number
-- luacheck: ignore 631
---@class Fielding
---@field fielders table<string, Fielder>
---@field fielders { first: Fielder, second: Fielder, shortstop: Fielder, third: Fielder, pitcher: Fielder, catcher: Fielder, left: Fielder, center: Fielder, right: Fielder }
---@field fielderHoldingBall Fielder | nil
Fielding = {}
@ -113,7 +114,7 @@ end
---@param deltaSeconds number
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderHoldingBall = nil
local fielderHoldingBall
for _, fielder in pairs(self.fielders) do
local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball)
if inCatchingRange and fielder.catchEligible then
@ -137,10 +138,14 @@ function Fielding.markIneligible(fielder)
end)
end
function Fielding:markAllIneligible()
function Fielding:markAllEligible(eligible)
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = false
fielder.catchEligible = eligible
end
end
function Fielding:resetEligibility()
self:markAllEligible(false)
playdate.timer.new(750, function()
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = true

View File

@ -7,7 +7,8 @@ function getDrawOffset(ballX, ballY)
if ballY > C.Screen.H or ballX >= C.BallOffscreen then
return 0, 0
end
offsetY = math.max(0, -1 * ballY)
-- Keep the ball approximately in the center, once it's past C.Center.y - 30
offsetY = math.max(0, (-1 * ballY) + C.Center.y - 30)
if ballX > 0 and ballX < C.Screen.W then
offsetX = 0
@ -17,7 +18,7 @@ function getDrawOffset(ballX, ballY)
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
end
return offsetX * 1.3, offsetY * 1.5
return offsetX * 1.3, offsetY
end
---@class Blipper

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -29,6 +29,7 @@ import 'assets.lua'
import 'draw/box-score.lua'
import 'draw/fielder.lua'
import 'draw/overlay.lua'
import 'draw/panner.lua'
import 'draw/player.lua'
import 'draw/transitions.lua'
@ -42,6 +43,7 @@ import 'dbg.lua'
import 'fielding.lua'
import 'graphics.lua'
import 'npc.lua'
import 'pitching.lua'
-- stylua: ignore end
-- TODO: Customizable field structure. E.g. stands and ads etc.
@ -80,9 +82,6 @@ local teams <const> = {
---@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
@ -97,6 +96,7 @@ local teams <const> = {
---@field private npc Npc
---@field private homeTeamBlipper Blipper
---@field private awayTeamBlipper Blipper
---@field private panner Panner
---@field private state MutableState
Game = {}
@ -124,6 +124,7 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
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),
@ -135,8 +136,6 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
inning = 1,
pitchIsOver = false,
didSwing = false,
secondsSinceLastRunnerMove = 0,
secondsSincePitchAllowed = 0,
battingTeamSprites = settings.awayTeamSprites,
fieldingTeamSprites = settings.homeTeamSprites,
runnerBlipper = runnerBlipper,
@ -151,7 +150,7 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
o.fielding:resetFielderPositions(teams.home.benchPosition)
playdate.timer.new(2000, function()
ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
o:returnToPitcher()
end)
BootTune:play()
@ -170,22 +169,22 @@ local Pitches <const> = {
-- 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),
x = gfx.animator.new(0, C.PitchStart.x, C.PitchStart.x, playdate.easingFunctions.linear),
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStart.y, 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),
x = gfx.animator.new(C.PitchFlyMs, C.PitchStart.x + 20, C.PitchStart.x, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, 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),
x = gfx.animator.new(C.PitchFlyMs, C.PitchStart.x - 20, C.PitchStart.x, utils.easingHill),
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
-- Wobbbleball
@ -193,11 +192,11 @@ local Pitches <const> = {
return {
x = {
currentValue = function()
return C.PitchStartX + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStartY) / 10))
return C.PitchStart.x + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStart.y) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
}
end,
}
@ -260,7 +259,18 @@ function Game:pitch(pitchFlyTimeMs, pitchTypeIndex)
self.state.ball.yAnimator:reset()
end
self.state.secondsSincePitchAllowed = 0
pitchTracker.secondsSinceLastPitch = 0
end
function Game:pitcherIsReady()
local pitcher = self.fielding.fielders.pitcher
local pitcherIsOnTheMound = utils.distanceBetweenPoints(pitcher, C.PitcherStartPos) < C.BaseHitbox
return 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:nextHalfInning()
@ -279,7 +289,6 @@ function Game:nextHalfInning()
end
Fielding.celebrate()
self.state.secondsSinceLastRunnerMove = -7
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...")
@ -338,14 +347,14 @@ function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
end
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
self.state.secondsSinceLastRunnerMove = 0
self.baserunning.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running
return true
end
function Game:nextBatter()
self.state.secondsSincePitchAllowed = -3
pitchTracker.secondsSinceLastPitch = -3
self.baserunning.batter = nil
playdate.timer.new(2000, function()
pitchTracker:reset()
@ -390,6 +399,7 @@ function Game:updateBatting(batDeg, batSpeed)
if
batSpeed > 0
and self.state.ball.y < 232
and utils.pointDirectlyUnderLine(
self.state.ball.x,
self.state.ball.y,
@ -399,7 +409,6 @@ function Game:updateBatting(batDeg, batSpeed)
self.state.batTip.y,
C.Screen.H
)
and self.state.ball.y < 232
then
-- Hit!
BatCrackReverb:play()
@ -434,6 +443,12 @@ function Game:updateBatting(batDeg, batSpeed)
if utils.within(1, self.state.ball.x, ballDestX) and utils.within(1, self.state.ball.y, ballDestY) 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
@ -447,14 +462,20 @@ function Game:updateBatting(batDeg, batSpeed)
end
---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored =
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
if runnersScored ~= 0 then
self:score(runnersScored)
end
return runnerMoved
return runnersStillMoving, secondsSinceLastRunnerMove
end
function Game:returnToPitcher()
self.fielding:resetEligibility()
self.fielding.fielders.pitcher.catchEligible = true
self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
end
---@param throwFly number
@ -494,18 +515,17 @@ function Game:updateGameState()
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
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
end
if self.state.secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
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
-- Catcher has the ball. Throw it back to the pitcher
self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
self:returnToPitcher()
self.state.pitchIsOver = true
self.state.didSwing = false
end
@ -525,7 +545,7 @@ function Game:updateGameState()
-- Walk batter to the plate
self.baserunning:updateRunner(self.baserunning.batter, nil, crankLimited, self.state.deltaSeconds)
if self.state.secondsSincePitchAllowed > C.PitchAfterSeconds then
if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly and not self:buttonControlledThrow(throwFly, true) then
@ -540,32 +560,32 @@ function Game:updateGameState()
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
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, 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
elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
self.state.offenseState = C.Offense.batting
end
elseif self.state.offenseState == C.Offense.homeRun then
self:updateNonBatterRunners(C.WalkedRunnerSpeed * 3, false)
self:updateNonBatterRunners(C.WalkedRunnerSpeed * 2, false)
if #self.baserunning.runners == 0 then
self.state.offenseState = C.Offense.batting
self.baserunning:pushNewBatter()
-- Give the player a moment to enjoy their home run.
self.fielding:resetFielderPositions()
playdate.timer.new(1500, function()
self:returnToPitcher()
end)
actionQueue:upsert("waitForPitcherToHaveBall", 10000, function()
while not self:pitcherIsReady() do
coroutine.yield()
end
self.fielding:markAllEligible(true)
self.state.offenseState = C.Offense.batting
end)
end
end
@ -597,10 +617,10 @@ function Game:update()
gfx.clear()
gfx.setColor(gfx.kColorBlack)
local offsetX, offsetY = getDrawOffset(ball.x, ball.y)
local offsetX, offsetY = self.panner:get(self.state.deltaSeconds)
gfx.setDrawOffset(offsetX, offsetY)
GrassBackground:draw(-400, -240)
GrassBackground:draw(-400, -720)
---@type { y: number, drawAction: fun() }[]
local characterDraws = {}

83
src/pitching.lua Normal file
View File

@ -0,0 +1,83 @@
PitchOutcomes = {
StrikeOut = "StrikeOut",
Walk = "Walk",
}
pitchTracker = {
--- Position of the pitch, or nil, if one has not been recorded.
---@type number | nil
recordedPitchX = nil,
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
secondsSinceLastPitch = 0,
strikes = 0,
balls = 0,
}
function pitchTracker:reset()
self.strikes = 0
self.balls = 0
end
function pitchTracker:recordIfPassed(ball)
if ball.y < C.StrikeZoneStartY then
self.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then
self.recordedPitchX = ball.x
end
end
---@param didSwing boolean
---@param fieldingTeamInningData TeamInningData
function pitchTracker:updatePitchCounts(didSwing, fieldingTeamInningData)
if not self.recordedPitchX then
return
end
local currentPitchingStats = fieldingTeamInningData.pitching
if didSwing or self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
self.strikes = self.strikes + 1
currentPitchingStats.strikes = currentPitchingStats.strikes + 1
if self.strikes >= 3 then
self:reset()
return PitchOutcomes.StrikeOut
end
else
self.balls = self.balls + 1
currentPitchingStats.balls = currentPitchingStats.balls + 1
if self.balls >= 4 then
self:reset()
return PitchOutcomes.Walk
end
end
end
-----------------
-- Throw Meter --
-----------------
throwMeter = {
value = 0,
}
function throwMeter:reset()
self.value = 0
end
---@return number | nil flyTimeMs Returns nil when a throw is NOT requested.
function throwMeter:readThrow()
if self.value > C.ThrowMeterMax then
return (C.PitchFlyMs / (self.value / C.ThrowMeterMax))
end
return nil
end
--- Applies the given charge, but drains some meter for how much time has passed
---@param deltaSeconds number
---@param chargeAmount number
function throwMeter:applyCharge(deltaSeconds, chargeAmount)
self.value = math.max(0, self.value - (deltaSeconds * C.ThrowMeterDrainPerSec))
self.value = self.value + math.abs(chargeAmount * C.UserThrowPower)
end

View File

@ -70,17 +70,24 @@ end
---@param mover { x: number, y: number }
---@param speed number
---@param target { x: number, y: number }
---@return boolean
function utils.moveAtSpeed(mover, speed, target)
---@param tau number | nil
---@return boolean isStillMoving
function utils.moveAtSpeed(mover, speed, target, tau)
local x, y, distance = utils.normalizeVector(mover.x, mover.y, target.x, target.y)
if distance > 1 then
mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed)
return true
if distance == 0 then
return false
end
return false
if distance > (tau or 1) then
mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed)
else
mover.x = target.x
mover.y = target.y
end
return true
end
function utils.within(within, n1, n2)
@ -249,87 +256,6 @@ function utils.totalScores(stats)
return homeScore, awayScore
end
PitchOutcomes = {
StrikeOut = "StrikeOut",
Walk = "Walk",
}
pitchTracker = {
--- Position of the pitch, or nil, if one has not been recorded.
---@type number | nil
recordedPitchX = nil,
strikes = 0,
balls = 0,
}
function pitchTracker:reset()
self.strikes = 0
self.balls = 0
end
function pitchTracker:recordIfPassed(ball)
if ball.y < C.StrikeZoneStartY then
self.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then
self.recordedPitchX = ball.x
end
end
---@param didSwing boolean
---@param fieldingTeamInningData TeamInningData
function pitchTracker:updatePitchCounts(didSwing, fieldingTeamInningData)
if not self.recordedPitchX then
return
end
local currentPitchingStats = fieldingTeamInningData.pitching
if didSwing or self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
self.strikes = self.strikes + 1
currentPitchingStats.strikes = currentPitchingStats.strikes + 1
if self.strikes >= 3 then
self:reset()
return PitchOutcomes.StrikeOut
end
else
self.balls = self.balls + 1
currentPitchingStats.balls = currentPitchingStats.balls + 1
if self.balls >= 4 then
self:reset()
return PitchOutcomes.Walk
end
end
end
-----------------
-- Throw Meter --
-----------------
throwMeter = {
value = 0,
}
function throwMeter:reset()
self.value = 0
end
---@return number | nil flyTimeMs Returns nil when a throw is NOT requested.
function throwMeter:readThrow()
if self.value > C.ThrowMeterMax then
return (C.PitchFlyMs / (self.value / C.ThrowMeterMax))
end
return nil
end
--- Applies the given charge, but drains some meter for how much time has passed
---@param delta number
---@param chargeAmount number
function throwMeter:applyCharge(delta, chargeAmount)
self.value = math.max(0, self.value - (delta * C.ThrowMeterDrainPerSec))
self.value = self.value + math.abs(chargeAmount * C.UserThrowPower)
end
if not playdate then
return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes, throwMeter = throwMeter }
return utils
end