diff --git a/src/constants.lua b/src/constants.lua index fddcfc1..a237178 100644 --- a/src/constants.lua +++ b/src/constants.lua @@ -108,9 +108,6 @@ C.PitcherStartPos = { y = C.Screen.H * 0.40, } -C.ThrowMeterMax = 10 -C.ThrowMeterDrainPerSec = 150 - --- Controls how hard the ball can be hit, and --- how fast the ball can be thrown. C.CrankPower = 10 diff --git a/src/draw/throw-meter.lua b/src/draw/throw-meter.lua new file mode 100644 index 0000000..f00535b --- /dev/null +++ b/src/draw/throw-meter.lua @@ -0,0 +1,27 @@ +---@type pd_graphics_lib +local gfx = playdate.graphics + +local ThrowMeterHeight = 50 + +---@param x number +---@param y number +function throwMeter:draw(x, y) + gfx.setLineWidth(1) + gfx.drawRect(x, y, 14, ThrowMeterHeight) + if self.lastReadThrow then + -- TODO: If ratio is "perfect", show some additional effect + local ratio = (self.lastReadThrow - throwMeter.MinCharge) / (self.idealPower - throwMeter.MinCharge) + local height = ThrowMeterHeight * ratio + gfx.fillRect(x + 2, y + ThrowMeterHeight - height, 10, height) + end + -- TODO: Dither or bend if the user throws too hard + -- Or maybe dither if it's too soft - bend if it's too hard +end + + +function throwMeter:drawNearFielder(fielder) + if not fielder then + return + end + throwMeter:draw(fielder.x - 25, fielder.y - 10) +end \ No newline at end of file diff --git a/src/fielding.lua b/src/fielding.lua index 223cef6..a4b874e 100644 --- a/src/fielding.lua +++ b/src/fielding.lua @@ -92,24 +92,30 @@ local function updateFielderPosition(deltaSeconds, fielder, ballPos) return utils.distanceBetweenPoints(fielder, ballPos) < C.BallCatchHitbox end +-- TODO: Prevent multiple fielders covering the same base. +-- At least in a how-about-everybody-stand-right-here way. + --- Selects the nearest fielder to move toward the given coordinates. --- Other fielders should attempt to cover their bases ----@param self table ---@param ballDestX number ---@param ballDestY number function Fielding:haveSomeoneChase(ballDestX, ballDestY) local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY) - chasingFielder.target = { x = ballDestX, y = ballDestY } + chasingFielder.target = utils.xy(ballDestX, ballDestY) for _, base in ipairs(C.Bases) do local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder) - -- For now, skipping the pitcher because they're considered closer to 2B than second or shortstop - return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher + -- Skip the pitcher for 2B - they're considered closer than second or shortstop. + if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then + return false + end + return fielder ~= chasingFielder end) nearest.target = base end end +--- # Also updates `ball.heldby` ---@param ball Ball ---@param deltaSeconds number ---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball diff --git a/src/main.lua b/src/main.lua index 874a27d..a4f58c7 100644 --- a/src/main.lua +++ b/src/main.lua @@ -26,13 +26,6 @@ import 'CoreLibs/ui.lua' import 'utils.lua' import 'constants.lua' 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' - import 'main-menu.lua' import 'action-queue.lua' @@ -44,6 +37,14 @@ import 'fielding.lua' import 'graphics.lua' import 'npc.lua' import 'pitching.lua' + +import 'draw/box-score.lua' +import 'draw/fielder.lua' +import 'draw/overlay.lua' +import 'draw/panner.lua' +import 'draw/player.lua' +import 'draw/throw-meter.lua' +import 'draw/transitions.lua' -- stylua: ignore end -- TODO: Customizable field structure. E.g. stands and ads etc. @@ -161,46 +162,6 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state) return o end ----@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ----@alias Pitch fun(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } - ----@type Pitch[] -local Pitches = { - -- Fastball - function() - return { - 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.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.PitchStart.x - 20, C.PitchStart.x, utils.easingHill), - y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), - } - end, - -- Wobbbleball - function(ball) - return { - x = { - currentValue = function() - 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.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), - } - end, -} - ---@param teamId TeamId ---@return TeamId local function getOppositeTeamId(teamId) @@ -282,6 +243,7 @@ function Game:nextHalfInning() local isFinalInning = self.state.inning >= self.settings.finalInning local gameOver = isFinalInning and self.state.battingTeam == "home" and awayScore ~= homeScore gameOver = gameOver or self.state.battingTeam == "away" and isFinalInning and homeScore > awayScore + Fielding.celebrate() if gameOver then self.announcer:say("THAT'S THE BALL GAME!") @@ -291,7 +253,6 @@ function Game:nextHalfInning() return end - Fielding.celebrate() self.fielding:benchTo(self:getFieldingTeam().benchPosition) self.announcer:say("SWITCHING SIDES...") @@ -381,7 +342,7 @@ end function Game:strikeOut() local outBatter = self.baserunning.batter self.baserunning.batter = nil - self.baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!") + self.baserunning:outRunner(outBatter, "Strike out!") self:nextBatter() end @@ -518,8 +479,13 @@ function Game:updateGameState() local userOnOffense, userOnDefense = self:userIsOn("offense") - if userOnDefense then - throwMeter:applyCharge(self.state.deltaSeconds, crankLimited) + 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 @@ -559,9 +525,12 @@ function Game:updateGameState() if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then if userOnDefense then - local throwFly = throwMeter:readThrow() - if throwFly and not self:buttonControlledThrow(throwFly, true) then - self:userPitch(throwFly) + local powerRatio, isPerfect = throwMeter:readThrow(crankChange) + if powerRatio then + local throwFly = C.PitchFlyMs / powerRatio + if throwFly and not self:buttonControlledThrow(throwFly, true) then + self:userPitch(throwFly) + end end else self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), 2) @@ -578,6 +547,16 @@ function Game:updateGameState() self.state.offenseState = C.Offense.batting self:returnToPitcher() end + + if userOnDefense then + local powerRatio, isPerfect = throwMeter:readThrow(crankChange) + if powerRatio then + local throwFly = C.PitchFlyMs / powerRatio + if throwFly then + self:buttonControlledThrow(throwFly) + end + end + end elseif self.state.offenseState == C.Offense.walking then if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then self.state.offenseState = C.Offense.batting @@ -599,21 +578,6 @@ function Game:updateGameState() end end - local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) - - if userOnDefense then - local throwFly = throwMeter:readThrow() - if throwFly then - self:buttonControlledThrow(throwFly) - end - end - 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 - self.baserunning:walkAwayOutRunners(self.state.deltaSeconds) actionQueue:runWaiting(self.state.deltaSeconds) end @@ -639,15 +603,19 @@ function Game:update() end local danceOffset = FielderDanceAnimator:currentValue() - local ballIsHeld = false + ---@type Fielder | nil + local ballHeldBy for _, fielder in pairs(self.fielding.fielders) do addDraw(fielder.y + danceOffset, function() - ballIsHeld = drawFielder( + local ballHeldByThisFielder = drawFielder( self.state.fieldingTeamSprites, self.state.ball, fielder.x, fielder.y + danceOffset - ) or ballIsHeld + ) + if ballHeldByThisFielder then + ballHeldBy = fielder + end end) end @@ -686,7 +654,9 @@ function Game:update() self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset) end - if not ballIsHeld then + throwMeter:drawNearFielder(ballHeldBy) + + if not ballHeldBy then gfx.setLineWidth(2) gfx.setColor(gfx.kColorWhite) diff --git a/src/pitching.lua b/src/pitching.lua index 94412e0..7db3d08 100644 --- a/src/pitching.lua +++ b/src/pitching.lua @@ -1,3 +1,46 @@ +---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } +---@alias Pitch fun(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } + +---@type pd_graphics_lib +local gfx = playdate.graphics + +---@type Pitch[] +Pitches = { + -- Fastball + function() + return { + 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.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.PitchStart.x - 20, C.PitchStart.x, utils.easingHill), + y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), + } + end, + -- Wobbbleball + function(ball) + return { + x = { + currentValue = function() + 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.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), + } + end, +} + PitchOutcomes = { StrikeOut = "StrikeOut", Walk = "Walk", @@ -59,25 +102,60 @@ end ----------------- throwMeter = { + MinCharge = 45, + value = 0, + idealPower = 90, + lastReadThrow = nil, } 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)) +local crankQueue = {} + +--- Returns nil when a throw is NOT requested. +---@param chargeAmount number +---@return number | nil powerRatio, boolean isPerfect +function throwMeter:readThrow(chargeAmount) + local ret = self:applyCharge(chargeAmount) + if ret then + local ratio = ret / self.idealPower + return ratio, math.abs(ratio - 1) < 0.05 end - return nil + return nil, false end ---- Applies the given charge, but drains some meter for how much time has passed ----@param deltaSeconds number +--- If (within approx. a third of a second) the crank has moved more than 45 degrees, call that a throw. ---@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 +function throwMeter:applyCharge(chargeAmount) + if chargeAmount == 0 then + return + end + local currentTimeMs = playdate.getCurrentTimeMilliseconds() + local removedOne = false + while #crankQueue ~= 0 and (currentTimeMs - crankQueue[1].time) > 0.33 do + table.remove(crankQueue, 1) + removedOne = true -- At least 1/3 second has passed + end + crankQueue[#crankQueue + 1] = { time = currentTimeMs, chargeAmount = math.abs(chargeAmount) } + + if not removedOne then + return nil + end + + local currentCharge = 0 + for _, v in ipairs(crankQueue) do + currentCharge = currentCharge + v.chargeAmount + end + + if currentCharge > throwMeter.MinCharge then + self.lastReadThrow = currentCharge + print(tostring(currentCharge) .. " from " .. #crankQueue) + crankQueue = {} + return currentCharge + else + return nil + end +end \ No newline at end of file