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:
parent
52434fe891
commit
b003c148a4
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
116
src/main.lua
116
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 <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)
|
||||
|
|
100
src/pitching.lua
100
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 <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
|
Loading…
Reference in New Issue