Extract more into pitching.

More consistent (and visible!) throw meter.
It's still imperfect (by a lot!) but it feels much more controlled.
Throws are a little too soft right now, but it's in a halfway decent state.
This commit is contained in:
Sage Vaillancourt 2025-02-19 22:32:10 -05:00
parent 52434fe891
commit b003c148a4
5 changed files with 169 additions and 91 deletions

View File

@ -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

27
src/draw/throw-meter.lua Normal file
View File

@ -0,0 +1,27 @@
---@type pd_graphics_lib
local gfx <const> = playdate.graphics
local ThrowMeterHeight <const> = 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

View File

@ -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

View File

@ -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 <const> = {
-- 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)

View File

@ -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 <const> = 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