Merge pull request 'extract-input-controllers' (#4) from extract-input-controllers into main

Reviewed-on: #4
This commit is contained in:
sage 2025-02-24 19:50:23 -05:00
commit 30aa5bd6c6
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
---@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(
--- self: self,
--- destX: number,
@ -39,6 +46,7 @@ import 'graphics.lua'
import 'npc.lua'
import 'pitching.lua'
import 'statistics.lua'
import 'user-input.lua'
import 'draw/box-score.lua'
import 'draw/fans.lua'
@ -98,7 +106,8 @@ local teams <const> = {
---@field private announcer Announcer
---@field private fielding Fielding
---@field private baserunning Baserunning
---@field private npc Npc
---@field private npc InputHandler
---@field private userInput InputHandler
---@field private homeTeamBlipper Blipper
---@field private awayTeamBlipper Blipper
---@field private panner Panner
@ -109,7 +118,7 @@ Game = {}
---@param announcer Announcer | nil
---@param fielding Fielding | nil
---@param baserunning Baserunning | nil
---@param npc Npc | nil
---@param npc InputHandler | nil
---@param state MutableState | nil
---@return Game
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:nextHalfInning()
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.fielding:resetFielderPositions(teams.home.benchPosition)
@ -198,6 +210,17 @@ function Game:userIsOn(side)
return ret, not ret
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 pitchTypeIndex number | nil
---@param accuracy number The closer to 1.0, the better
@ -308,18 +331,18 @@ end
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third]
targetBase = C.Third
elseif playdate.buttonIsPressed(playdate.kButtonUp) then
targetBase = C.Bases[C.Second]
targetBase = C.Second
elseif playdate.buttonIsPressed(playdate.kButtonRight) then
targetBase = C.Bases[C.First]
targetBase = C.First
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
targetBase = C.Bases[C.Home]
targetBase = C.Home
else
return false
end
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
self.fielding:userThrowTo(C.Bases[targetBase], self.state.ball, throwFlyMs)
self.baserunning.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running
@ -358,8 +381,12 @@ end
local SwingBackDeg <const> = 30
local SwingForwardDeg <const> = 170
---@param batDeg number
function Game:updateBatting(batDeg, batSpeed)
---@param offenseHandler InputHandler
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
self.state.didSwing = true
end
@ -368,81 +395,80 @@ function Game:updateBatting(batDeg, batSpeed)
-- and letting the user find a crank position and direction that works for them
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
local batter = self.baserunning.batter
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.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
if
batSpeed > 0
and self.state.ball.y < 232
local ballWasHit = batSpeed > 0
and ball.y < 232
and utils.pointDirectlyUnderLine(
self.state.ball.x,
self.state.ball.y,
ball.x,
ball.y,
self.state.batBase.x,
self.state.batBase.y,
self.state.batTip.x,
self.state.batTip.y,
C.Screen.H
)
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 ballDest =
utils.xy(self.state.ball.x + (ballVelX * C.BattingPower), self.state.ball.y + (ballVelY * C.BattingPower))
pitchTracker:reset()
local flyTimeMs = 2000
-- TODO? A dramatic eye-level view on a home-run could be sick.
local battingTeamStats = self:battingTeamCurrentInning()
battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest
if utils.isFoulBall(ballDest.x, ballDest.y) 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
if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then
playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted
if
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.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
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)
self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ballDest.x, ballDest.y)
if not ballWasHit then
return
end
-- Hit!
BatCrackReverb:play()
self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15)
local ballVelX = mult * C.BattingPower * 10 * math.sin(ballAngle)
local ballVelY = mult * C.BattingPower * 5 * math.cos(ballAngle)
if ballVelY > 0 then
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
local ballDest = utils.xy(ball.x + ballVelX, ball.y + ballVelY)
pitchTracker:reset()
local flyTimeMs = 2000
-- TODO? A dramatic eye-level view on a home-run could be sick.
local battingTeamStats = self:battingTeamCurrentInning()
battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest
if utils.isFoulBall(ballDest.x, ballDest.y) 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
if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then
playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted
if utils.within(1, ball.x, ballDest.x) and utils.within(1, ball.y, ballDest.y) 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
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler)
self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ballDest.x, ballDest.y)
end
---@param appliedSpeed number | fun(runner: Runner): number
---
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun)
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
@ -471,102 +497,53 @@ function Game:returnToPitcher()
end)
end
---@param throwFly number
function Game:userPitch(throwFly, accuracy)
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)
---@param defenseHandler InputHandler
function Game:updatePitching(defenseHandler)
pitchTracker:recordIfPassed(self.state.ball)
if self:pitcherIsOnTheMound() then
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
end
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
self:returnToPitcher()
self.state.pitchIsOver = true
self.state.didSwing = false
end
if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
self:pitch(defenseHandler:pitch())
end
end
function Game:updateGameState()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
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 offenseHandler, defenseHandler = self:currentInputHandlers()
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)
local pitcher = self.fielding.fielders.pitcher
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then
pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
end
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
self:returnToPitcher()
self.state.pitchIsOver = true
self.state.didSwing = false
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.pitchIsOver, self.state.deltaSeconds)
batSpeed = self.npc:batSpeed() * self.state.deltaSeconds
end
self:updateBatting(self.state.batAngleDeg, batSpeed)
self:updatePitching(defenseHandler)
self:updateBatting(offenseHandler)
-- Walk batter to the plate
self.baserunning:updateRunner(
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
self.baserunning:updateRunner(self.baserunning.batter, nil, 0, false, self.state.deltaSeconds)
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
local appliedSpeed = function(runner)
return offenseHandler:runningSpeed(runner, self.state.ball)
end
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false)
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher
@ -574,15 +551,11 @@ function Game:updateGameState()
self:returnToPitcher()
end
if userOnDefense then
local powerRatio = throwMeter:readThrow(crankChange)
if powerRatio then
local throwFly = C.PitchFlyMs / powerRatio
if throwFly then
self:buttonControlledThrow(throwFly)
end
end
local outedSomeRunner = false
if fielderHoldingBall then
outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
end
defenseHandler:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true, true) then
self.state.offenseState = C.Offense.batting
@ -608,10 +581,7 @@ function Game:updateGameState()
end
function Game:update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
self:updateGameState()
local ball = self.state.ball
gfx.clear()
gfx.setColor(gfx.kColorBlack)
@ -628,6 +598,8 @@ function Game:update()
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
end
local ball = self.state.ball
local danceOffset = FielderDanceAnimator:currentValue()
---@type Fielder | nil
local ballHeldBy
@ -635,7 +607,7 @@ function Game:update()
addDraw(fielder.y + danceOffset, function()
local ballHeldByThisFielder = drawFielder(
self.state.fieldingTeamSprites[fielder.spriteIndex],
self.state.ball,
ball,
fielder.x,
fielder.y + danceOffset
)

View File

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