diff --git a/src/main.lua b/src/main.lua index ddef83e..709f7f9 100644 --- a/src/main.lua +++ b/src/main.lua @@ -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 = { ---@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 = 30 local SwingForwardDeg = 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 ) diff --git a/src/npc.lua b/src/npc.lua index 533450c..be0eb24 100644 --- a/src/npc.lua +++ b/src/npc.lua @@ -2,7 +2,7 @@ local npcBatDeg = 0 local BaseNpcBatSpeed = 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() diff --git a/src/pitching.lua b/src/pitching.lua index 42381a3..18d9aa3 100644 --- a/src/pitching.lua +++ b/src/pitching.lua @@ -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, diff --git a/src/user-input.lua b/src/user-input.lua new file mode 100644 index 0000000..02a78b9 --- /dev/null +++ b/src/user-input.lua @@ -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