extract-input-controllers #4

Merged
sage merged 5 commits from extract-input-controllers into main 2025-02-24 19:50:23 -05:00
4 changed files with 214 additions and 168 deletions

View File

@ -13,6 +13,13 @@ import 'CoreLibs/utilities/where.lua'
--- @alias EasingFunc fun(number, number, number, number): number --- @alias EasingFunc fun(number, number, number, number): number
---@class InputHandler
---@field update fun(self, deltaSeconds: number)
---@field updateBat fun(self, ball: Ball, pitchIsOver: boolean, deltaSeconds: number)
---@field runningSpeed fun(self, runner: Runner, ball: Ball)
---@field pitch fun(self)
---@field fielderAction fun(self, fielderHoldingBall: Fielder | nil, outedSomeRunner: boolean, ball: Ball)
--- @alias LaunchBall fun( --- @alias LaunchBall fun(
--- self: self, --- self: self,
--- destX: number, --- destX: number,
@ -39,6 +46,7 @@ import 'graphics.lua'
import 'npc.lua' import 'npc.lua'
import 'pitching.lua' import 'pitching.lua'
import 'statistics.lua' import 'statistics.lua'
import 'user-input.lua'
import 'draw/box-score.lua' import 'draw/box-score.lua'
import 'draw/fans.lua' import 'draw/fans.lua'
@ -98,7 +106,8 @@ local teams <const> = {
---@field private announcer Announcer ---@field private announcer Announcer
---@field private fielding Fielding ---@field private fielding Fielding
---@field private baserunning Baserunning ---@field private baserunning Baserunning
---@field private npc Npc ---@field private npc InputHandler
---@field private userInput InputHandler
---@field private homeTeamBlipper Blipper ---@field private homeTeamBlipper Blipper
---@field private awayTeamBlipper Blipper ---@field private awayTeamBlipper Blipper
---@field private panner Panner ---@field private panner Panner
@ -109,7 +118,7 @@ Game = {}
---@param announcer Announcer | nil ---@param announcer Announcer | nil
---@param fielding Fielding | nil ---@param fielding Fielding | nil
---@param baserunning Baserunning | nil ---@param baserunning Baserunning | nil
---@param npc Npc | nil ---@param npc InputHandler | nil
---@param state MutableState | nil ---@param state MutableState | nil
---@return Game ---@return Game
function Game.new(settings, announcer, fielding, baserunning, npc, state) function Game.new(settings, announcer, fielding, baserunning, npc, state)
@ -151,6 +160,9 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
o.baserunning = baserunning or Baserunning.new(announcer, function() o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning() o:nextHalfInning()
end) end)
o.userInput = UserInput.new(function(throwFly, forbidThrowHome)
return o:buttonControlledThrow(throwFly, forbidThrowHome)
end)
o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders) o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)
o.fielding:resetFielderPositions(teams.home.benchPosition) o.fielding:resetFielderPositions(teams.home.benchPosition)
@ -198,6 +210,17 @@ function Game:userIsOn(side)
return ret, not ret return ret, not ret
end end
---@return InputHandler offense, InputHandler defense
function Game:currentInputHandlers()
local userOnOffense, userOnDefense = self:userIsOn("offense")
local offenseInput = userOnOffense and self.userInput or self.npc
local defenseInput = userOnDefense and self.userInput or self.npc
offenseInput:update(self.state.deltaSeconds)
defenseInput:update(self.state.deltaSeconds)
return offenseInput, defenseInput
end
---@param pitchFlyTimeMs number | nil ---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil ---@param pitchTypeIndex number | nil
---@param accuracy number The closer to 1.0, the better ---@param accuracy number The closer to 1.0, the better
@ -308,18 +331,18 @@ end
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome) function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third] targetBase = C.Third
elseif playdate.buttonIsPressed(playdate.kButtonUp) then elseif playdate.buttonIsPressed(playdate.kButtonUp) then
targetBase = C.Bases[C.Second] targetBase = C.Second
elseif playdate.buttonIsPressed(playdate.kButtonRight) then elseif playdate.buttonIsPressed(playdate.kButtonRight) then
targetBase = C.Bases[C.First] targetBase = C.First
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
targetBase = C.Bases[C.Home] targetBase = C.Home
else else
return false return false
end end
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs) self.fielding:userThrowTo(C.Bases[targetBase], self.state.ball, throwFlyMs)
self.baserunning.secondsSinceLastRunnerMove = 0 self.baserunning.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running self.state.offenseState = C.Offense.running
@ -358,8 +381,12 @@ end
local SwingBackDeg <const> = 30 local SwingBackDeg <const> = 30
local SwingForwardDeg <const> = 170 local SwingForwardDeg <const> = 170
---@param batDeg number ---@param offenseHandler InputHandler
function Game:updateBatting(batDeg, batSpeed) function Game:updateBatting(offenseHandler)
local ball = self.state.ball
local batDeg, batSpeed = offenseHandler:updateBat(ball, self.state.pitchIsOver, self.state.deltaSeconds)
self.state.batAngleDeg = batDeg
if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then
self.state.didSwing = true self.state.didSwing = true
end end
@ -368,39 +395,42 @@ function Game:updateBatting(batDeg, batSpeed)
-- and letting the user find a crank position and direction that works for them -- and letting the user find a crank position and direction that works for them
local batAngle = math.rad(batDeg) local batAngle = math.rad(batDeg)
-- TODO: animate bat-flip or something -- TODO: animate bat-flip or something
self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0 local batter = self.baserunning.batter
self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0 self.state.batBase.x = batter and (batter.x + C.BatterHandPos.x) or -999
self.state.batBase.y = batter and (batter.y + C.BatterHandPos.y) or -999
self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle)) 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)) self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
if local ballWasHit = batSpeed > 0
batSpeed > 0 and ball.y < 232
and self.state.ball.y < 232
and utils.pointDirectlyUnderLine( and utils.pointDirectlyUnderLine(
self.state.ball.x, ball.x,
self.state.ball.y, ball.y,
self.state.batBase.x, self.state.batBase.x,
self.state.batBase.y, self.state.batBase.y,
self.state.batTip.x, self.state.batTip.x,
self.state.batTip.y, self.state.batTip.y,
C.Screen.H C.Screen.H
) )
then
if not ballWasHit then
return
end
-- Hit! -- Hit!
BatCrackReverb:play() BatCrackReverb:play()
self.state.offenseState = C.Offense.running self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90)
local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15) local mult = math.abs(batSpeed / 15)
local ballVelX = mult * 10 * math.sin(ballAngle) local ballVelX = mult * C.BattingPower * 10 * math.sin(ballAngle)
local ballVelY = mult * 5 * math.cos(ballAngle) local ballVelY = mult * C.BattingPower * 5 * math.cos(ballAngle)
if ballVelY > 0 then if ballVelY > 0 then
ballVelX = ballVelX * -1 ballVelX = ballVelX * -1
ballVelY = ballVelY * -1 ballVelY = ballVelY * -1
end end
local ballDest = local ballDest = utils.xy(ball.x + ballVelX, ball.y + ballVelY)
utils.xy(self.state.ball.x + (ballVelX * C.BattingPower), self.state.ball.y + (ballVelY * C.BattingPower))
pitchTracker:reset() pitchTracker:reset()
local flyTimeMs = 2000 local flyTimeMs = 2000
@ -418,9 +448,7 @@ function Game:updateBatting(batDeg, batSpeed)
if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then
playdate.timer.new(flyTimeMs, function() playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted -- Verify that the home run wasn't intercepted
if if utils.within(1, ball.x, ballDest.x) and utils.within(1, ball.y, ballDest.y) then
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.announcer:say("HOME RUN!")
self.state.offenseState = C.Offense.homeRun self.state.offenseState = C.Offense.homeRun
-- Linger on the home-run ball for a moment, before panning to the bases. -- Linger on the home-run ball for a moment, before panning to the bases.
@ -434,15 +462,13 @@ function Game:updateBatting(batDeg, batSpeed)
end end
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) 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) ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler)
self.baserunning:convertBatterToRunner() self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ballDest.x, ballDest.y) self.fielding:haveSomeoneChase(ballDest.x, ballDest.y)
end
end end
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
---
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove ---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun) function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun)
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove = local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
@ -471,48 +497,11 @@ function Game:returnToPitcher()
end) end)
end end
---@param throwFly number ---@param defenseHandler InputHandler
function Game:userPitch(throwFly, accuracy) function Game:updatePitching(defenseHandler)
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) pitchTracker:recordIfPassed(self.state.ball)
local pitcher = self.fielding.fielders.pitcher if self:pitcherIsOnTheMound() then
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
end end
@ -528,45 +517,33 @@ function Game:updateGameState()
self.state.didSwing = false self.state.didSwing = false
end end
local batSpeed if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
if userOnOffense then self:pitch(defenseHandler:pitch())
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 end
end
self:updateBatting(self.state.batAngleDeg, batSpeed) function Game:updateGameState()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
self.state.deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime()
self.state.ball:updatePosition()
local offenseHandler, defenseHandler = self:currentInputHandlers()
local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if self.state.offenseState == C.Offense.batting then
self:updatePitching(defenseHandler)
self:updateBatting(offenseHandler)
-- Walk batter to the plate -- Walk batter to the plate
self.baserunning:updateRunner( self.baserunning:updateRunner(self.baserunning.batter, nil, 0, false, self.state.deltaSeconds)
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 elseif self.state.offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited local appliedSpeed = function(runner)
or function(runner) return offenseHandler:runningSpeed(runner, self.state.ball)
return self.npc:runningSpeed(runner, self.state.ball)
end end
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false) local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false)
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher -- End of play. Throw the ball back to the pitcher
@ -574,15 +551,11 @@ function Game:updateGameState()
self:returnToPitcher() self:returnToPitcher()
end end
if userOnDefense then local outedSomeRunner = false
local powerRatio = throwMeter:readThrow(crankChange) if fielderHoldingBall then
if powerRatio then outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
local throwFly = C.PitchFlyMs / powerRatio
if throwFly then
self:buttonControlledThrow(throwFly)
end
end
end end
defenseHandler:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
elseif self.state.offenseState == C.Offense.walking then elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true, true) then if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true, true) then
self.state.offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
@ -608,10 +581,7 @@ function Game:updateGameState()
end end
function Game:update() function Game:update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
self:updateGameState() self:updateGameState()
local ball = self.state.ball
gfx.clear() gfx.clear()
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
@ -628,6 +598,8 @@ function Game:update()
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction } characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
end end
local ball = self.state.ball
local danceOffset = FielderDanceAnimator:currentValue() local danceOffset = FielderDanceAnimator:currentValue()
---@type Fielder | nil ---@type Fielder | nil
local ballHeldBy local ballHeldBy
@ -635,7 +607,7 @@ function Game:update()
addDraw(fielder.y + danceOffset, function() addDraw(fielder.y + danceOffset, function()
local ballHeldByThisFielder = drawFielder( local ballHeldByThisFielder = drawFielder(
self.state.fieldingTeamSprites[fielder.spriteIndex], self.state.fieldingTeamSprites[fielder.spriteIndex],
self.state.ball, ball,
fielder.x, fielder.x,
fielder.y + danceOffset fielder.y + danceOffset
) )

View File

@ -2,7 +2,7 @@ local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1500 local BaseNpcBatSpeed <const> = 1500
local npcBatSpeed = 1500 local npcBatSpeed = 1500
---@class Npc ---@class Npc: InputHandler
---@field runners Runner[] ---@field runners Runner[]
---@field fielders Fielder[] ---@field fielders Fielder[]
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)
@ -18,26 +18,32 @@ function Npc.new(runners, fielders)
}, { __index = Npc }) }, { __index = Npc })
end end
function Npc.update() end
-- TODO: FAR more nuanced NPC batting. -- TODO: FAR more nuanced NPC batting.
---@param ball XyPair ---@param ball XyPair
---@param pitchIsOver boolean ---@param pitchIsOver boolean
---@param deltaSec number ---@param deltaSec number
---@return number ---@return number batAngleDeg, number batSpeed
-- luacheck: no unused -- luacheck: no unused
function Npc:updateBatAngle(ball, pitchIsOver, deltaSec) function Npc:updateBat(ball, pitchIsOver, deltaSec)
if not pitchIsOver and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then if not pitchIsOver and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed) npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
else else
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
npcBatDeg = 230 npcBatDeg = 230
end end
return npcBatDeg return npcBatDeg, (self:batSpeed() * deltaSec)
end end
function Npc:batSpeed() function Npc:batSpeed()
return npcBatSpeed / 1.5 return npcBatSpeed / 1.5
end end
function Npc:pitch()
return C.PitchFlyMs / self:pitchSpeed(), math.random(#Pitches), 0.9
end
local baseRunningSpeed = 25 local baseRunningSpeed = 25
---@param runner Runner ---@param runner Runner
@ -138,6 +144,9 @@ end
---@param outedSomeRunner boolean ---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall } ---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
function Npc:fielderAction(fielder, outedSomeRunner, ball) function Npc:fielderAction(fielder, outedSomeRunner, ball)
if not fielder then
return
end
if outedSomeRunner then if outedSomeRunner then
-- Delay a little before the next play -- Delay a little before the next play
playdate.timer.new(750, function() playdate.timer.new(750, function()

View File

@ -54,12 +54,13 @@ Pitches = {
y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear),
} }
end, end,
-- Wobbbleball -- Wobbleball
function(accuracy, ball) function(accuracy, ball)
local missBy = getPitchMissBy(accuracy)
return { return {
x = { x = {
currentValue = function() currentValue = function()
return getPitchMissBy(accuracy) return missBy
+ C.PitchStart.x + C.PitchStart.x
+ (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStart.y) / 10)) + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStart.y) / 10))
end, end,

64
src/user-input.lua Normal file
View File

@ -0,0 +1,64 @@
---@class UserInput: InputHandler
---@field buttonControlledThrow: fun(throwFlyMs: number, forbidThrowHome: boolean): boolean didThrow
UserInput = {}
function UserInput.new(buttonControlledThrow)
return setmetatable({
buttonControlledThrow = buttonControlledThrow,
}, { __index = UserInput })
end
function UserInput:update()
self.crankChange = playdate.getCrankChange()
local crankLimited = self.crankChange == 0 and 0 or (math.log(math.abs(self.crankChange)) * C.CrankPower)
self.crankLimited = math.abs(crankLimited)
end
function UserInput:updateBat()
local batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
local batSpeed = self.crankLimited
return batAngleDeg, batSpeed
end
function UserInput:runningSpeed()
return self.crankLimited
end
---@param throwFlyMs number
---@param accuracy number | nil
---@return number | nil pitchFlyTimeMs, number | nil pitchTypeIndex, number | nil accuracy
local function userPitch(throwFlyMs, accuracy)
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
if not aPressed and not bPressed then
return throwFlyMs, 1, accuracy
elseif aPressed and not bPressed then
return throwFlyMs, 2, accuracy
elseif not aPressed and bPressed then
return throwFlyMs, 3, accuracy
elseif aPressed and bPressed then
return throwFlyMs, 4, accuracy
end
return nil, nil, nil
end
function UserInput:pitch()
local powerRatio, accuracy = throwMeter:readThrow(self.crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly and not self.buttonControlledThrow(throwFly, true) then
return userPitch(throwFly, accuracy)
end
end
end
function UserInput:fielderAction()
local powerRatio = throwMeter:readThrow(self.crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly then
self.buttonControlledThrow(throwFly, false)
end
end
end