Fielders can catch passing balls.

Implemented with a new catchEligible field. Should be easy enough to add some erroring to those catches, too.
Allow npc to control individual baserunners.
Add a bit more dirt to the infield.

This commit *does* introduce some entanglement between files. E.g. the markIneligible() calls, and overriding ball.heldBy from within Fielding
This commit is contained in:
Sage Vaillancourt 2025-02-12 17:18:18 -05:00
parent 1926960c86
commit a801b64f55
7 changed files with 104 additions and 55 deletions

View File

@ -1,6 +1,5 @@
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)
---@class Ball ---@class Ball
---@field private animator pd_animator_lib
---@field x number ---@field x number
---@field y number ---@field y number
---@field z number ---@field z number
@ -10,13 +9,15 @@
---@field yAnimator SimpleAnimator ---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator
---@field floatAnimator SimpleAnimator ---@field floatAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib
---@field private flyTimeMs number
Ball = {} Ball = {}
---@param animator pd_animator_lib ---@param animatorLib pd_animator_lib
---@return Ball ---@return Ball
function Ball.new(animator) function Ball.new(animatorLib)
return setmetatable({ return setmetatable({
animator = animator, animatorLib = animatorLib,
x = C.Center.x --[[@as number]], x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]], y = C.Center.y --[[@as number]],
z = 0, z = 0,
@ -29,10 +30,26 @@ function Ball.new(animator)
-- TODO? Replace these with a ballAnimatorZ? -- TODO? Replace these with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk -- ...that might lose some of the magic of both. Compromise available? idk
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius), sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
floatAnimator = animator.new(2000, -60, 0, utils.easingHill), floatAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
}, { __index = Ball }) }, { __index = Ball })
end end
function Ball:updatePosition()
if self.heldBy then
self.x = self.heldBy.x
self.y = self.heldBy.y
self.z = C.GloveZ
self.size = C.SmallestBallRadius
else
self.x = self.xAnimator:currentValue()
local z = self.floatAnimator:currentValue()
-- TODO: This `+ z` is more graphics logic than physics logic
self.y = self.yAnimator:currentValue() + z
self.z = z
self.size = self.sizeAnimator:currentValue()
end
end
--- Launches the ball from its current position to the given destination. --- Launches the ball from its current position to the given destination.
---@param destX number ---@param destX number
---@param destY number ---@param destY number
@ -47,14 +64,16 @@ function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScal
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
end end
self.flyTimeMs = flyTimeMs
if customBallScaler then if customBallScaler then
self.sizeAnimator = customBallScaler self.sizeAnimator = customBallScaler
else else
-- TODO? Scale based on distance? -- TODO? Scale based on distance?
self.sizeAnimator = self.animator.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill) self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
end end
self.yAnimator = self.animator.new(flyTimeMs, self.y, destY, easingFunc) self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
self.xAnimator = self.animator.new(flyTimeMs, self.x, destX, easingFunc) self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
if floaty then if floaty then
self.floatAnimator:reset(flyTimeMs) self.floatAnimator:reset(flyTimeMs)
end end

View File

@ -181,8 +181,10 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSecond
-- TODO: Do a better job drifting runners toward their bases when appliedSpeed is low/zero -- TODO: Do a better job drifting runners toward their bases when appliedSpeed is low/zero
if distance < 2 then if distance < 2 then
if runner.prevBase ~= nearestBase then
runner.prevBase = runner.nextBase runner.prevBase = runner.nextBase
runner.nextBase = C.NextBaseMap[runner.nextBase] runner.nextBase = C.NextBaseMap[runner.nextBase]
end
runner.forcedTo = nil runner.forcedTo = nil
return false, false return false, false
end end
@ -214,16 +216,21 @@ end
--- Update non-batter runners. --- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update --- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number ---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved, number runnersScored ---@return boolean someRunnerMoved, number runnersScored
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
local someRunnerMoved = false local someRunnerMoved = false
local runnersScored = 0 local runnersScored = 0
local speedIsFunction = type(appliedSpeed) == "function"
-- TODO: Filter for the runner closest to the currently-held direction button -- TODO: Filter for the runner closest to the currently-held direction button
for runnerIndex, runner in ipairs(self.runners) do for runnerIndex, runner in ipairs(self.runners) do
if runner ~= self.batter and (not forcedOnly or runner.forcedTo) then if runner ~= self.batter and (not forcedOnly or runner.forcedTo) then
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, appliedSpeed, deltaSeconds) local speed = appliedSpeed
if speedIsFunction then
speed = appliedSpeed(runner)
end
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
someRunnerMoved = someRunnerMoved or thisRunnerMoved someRunnerMoved = someRunnerMoved or thisRunnerMoved
if thisRunnerScored then if thisRunnerScored then
runnersScored = runnersScored + 1 runnersScored = runnersScored + 1

View File

@ -86,6 +86,7 @@ C.BaseHitbox = 10
C.BattingPower = 25 C.BattingPower = 25
C.BatterHandPos = utils.xy(25, 15) C.BatterHandPos = utils.xy(25, 15)
C.GloveZ = 0 -- 10
C.SmallestBallRadius = 6 C.SmallestBallRadius = 6

View File

@ -1,16 +1,16 @@
--- @alias Fielder { --- @class Fielder {
--- x: number, --- @field catchEligible boolean
--- y: number, --- @field x number
--- target: XyPair | nil, --- @field y number
--- speed: number, --- @field target XyPair | nil
--- } --- @field speed number
-- TODO: Run down baserunners in a pickle. -- TODO: Run down baserunners in a pickle.
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)
---@class Fielding ---@class Fielding
---@field fielders table<string, Fielder> ---@field fielders table<string, Fielder>
---@field fielderTouchingBall Fielder | nil ---@field fielderHoldingBall Fielder | nil
Fielding = {} Fielding = {}
local FielderDanceAnimator <const> = playdate.graphics.animator.new(1, 10, 0, utils.easingHill) local FielderDanceAnimator <const> = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
@ -23,6 +23,7 @@ local function newFielder(name, speed)
return { return {
name = name, name = name,
speed = speed * C.FielderRunMult, speed = speed * C.FielderRunMult,
catchEligible = true,
} }
end end
@ -40,7 +41,7 @@ function Fielding.new()
right = newFielder("Right", 50), right = newFielder("Right", 50),
}, },
---@type Fielder | nil ---@type Fielder | nil
fielderTouchingBall = nil, fielderHoldingBall = nil,
}, { __index = Fielding }) }, { __index = Fielding })
end end
@ -79,7 +80,7 @@ end
---@param deltaSeconds number ---@param deltaSeconds number
---@param fielder Fielder ---@param fielder Fielder
---@param ballPos XyPair ---@param ballPos XyPair
---@return boolean isTouchingBall ---@return boolean inCatchingRange
local function updateFielderPosition(deltaSeconds, fielder, ballPos) local function updateFielderPosition(deltaSeconds, fielder, ballPos)
if fielder.target ~= nil then if fielder.target ~= nil then
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
@ -108,40 +109,62 @@ function Fielding:haveSomeoneChase(ballDestX, ballDestY)
end end
end end
---@param ball XyPair ---@param ball Ball
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil fielderTouchingBall nil if no fielder is currently touching the ball ---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
function Fielding:updateFielderPositions(ball, deltaSeconds) function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderTouchingBall = nil local fielderHoldingBall = nil
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
local isTouchingBall = updateFielderPosition(deltaSeconds, fielder, ball) local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball)
if isTouchingBall then if inCatchingRange and fielder.catchEligible then
fielderTouchingBall = fielder -- TODO: Base this catch on fielder skill?
fielderHoldingBall = fielder
ball.heldBy = fielder -- How much havoc will this wreak?
end end
end end
-- TODO: The need is growing for a distinction between touching the ball and holding the ball. -- TODO: The need is growing for a distinction between touching the ball and holding the ball.
-- Or, at least, fielders need to start *stopping* the ball when they make contact with it. -- Or, at least, fielders need to start *stopping* the ball when they make contact with it.
-- Right now, a line-drive *through* first will be counted as an out. -- Right now, a line-drive *through* first will be counted as an out.
self.fielderTouchingBall = fielderTouchingBall self.fielderHoldingBall = fielderHoldingBall
return fielderTouchingBall return fielderHoldingBall
end
---@param fielder Fielder
function Fielding.markIneligible(fielder)
fielder.catchEligible = false
playdate.timer.new(500, function()
fielder.catchEligible = true
end)
end
function Fielding:markAllIneligible()
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = false
end
playdate.timer.new(750, function()
for _, fielder in pairs(self.fielders) do
fielder.catchEligible = true
end
end)
end end
-- TODO? Start moving target fielders close sooner? -- TODO? Start moving target fielders close sooner?
---@param field table ---@param field Fielding
---@param targetBase Base ---@param targetBase Base
---@param launchBall LaunchBall ---@param launchBall LaunchBall
---@param throwFlyMs number ---@param throwFlyMs number
---@return ActionResult ---@return ActionResult
local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs) local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs)
if field.fielderTouchingBall == nil then if field.fielderHoldingBall == nil then
return ActionResult.NeedsMoreTime return ActionResult.NeedsMoreTime
end end
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder) local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= field.fielderTouchingBall -- presumably, this is who will be doing the throwing return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end) end)
closestFielder.target = targetBase closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return ActionResult.Succeeded return ActionResult.Succeeded
end end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -148,6 +148,8 @@ end
---@param pitchFlyTimeMs number | nil ---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil ---@param pitchTypeIndex number | nil
local function pitch(pitchFlyTimeMs, pitchTypeIndex) local function pitch(pitchFlyTimeMs, pitchTypeIndex)
Fielding.markIneligible(fielding.fielders.pitcher)
ball.heldBy = nil
catcherThrownBall = false catcherThrownBall = false
offenseState = C.Offense.batting offenseState = C.Offense.batting
@ -318,7 +320,7 @@ local function updateBatting(batDeg, batSpeed)
end end
end end
---@param appliedSpeed number ---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved ---@return boolean someRunnerMoved
local function updateNonBatterRunners(appliedSpeed, forcedOnly) local function updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
@ -351,16 +353,7 @@ local function updateGameState()
crankLimited = crankLimited * -1 crankLimited = crankLimited * -1
end end
if ball.heldBy then ball:updatePosition()
ball.x = ball.heldBy.x
ball.y = ball.heldBy.y
ball.size = C.SmallestBallRadius
else
ball.x = ball.xAnimator:currentValue()
ball.z = ball.floatAnimator:currentValue()
ball.y = ball.yAnimator:currentValue() + ball.z
ball.size = ball.sizeAnimator:currentValue()
end
local userOnOffense, userOnDefense = userIsOn(C.Sides.offense) local userOnOffense, userOnDefense = userIsOn(C.Sides.offense)
@ -418,7 +411,10 @@ local function updateGameState()
end end
end end
elseif offenseState == C.Offense.running then elseif offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited or npc:runningSpeed(ball) local appliedSpeed = userOnOffense and crankLimited
or function(runner)
return npc:runningSpeed(runner, ball)
end
if updateNonBatterRunners(appliedSpeed) then if updateNonBatterRunners(appliedSpeed) then
secondsSinceLastRunnerMove = 0 secondsSinceLastRunnerMove = 0
else else
@ -426,6 +422,7 @@ local function updateGameState()
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher -- End of play. Throw the ball back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
fielding:resetFielderPositions() fielding:resetFielderPositions()
offenseState = C.Offense.batting offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter() -- TODO: Remove, or replace with nextBatter()

View File

@ -39,26 +39,25 @@ end
local baseRunningSpeed = 25 local baseRunningSpeed = 25
--- TODO: Individual runner control. --- TODO: Individual runner control.
---@param runner Runner
---@param ball Point3d ---@param ball Point3d
---@return number ---@return number
function Npc:runningSpeed(ball) function Npc:runningSpeed(runner, ball)
if #self.runners == 0 then if #self.runners == 0 then
return 0 return 0
end end
local runner1 = self.runners[1] local distanceFromBall = utils.distanceBetweenZ(ball.x, ball.y, ball.z, runner.x, runner.y, 0)
local ballIsFar = utils.distanceBetweenZ(ball.x, ball.y, ball.z, runner1.x, runner1.y, 0) > 300 if distanceFromBall > 400 or runner.forcedTo then
if ballIsFar or runner1.forcedTo then
return baseRunningSpeed return baseRunningSpeed
end end
local touchedBase = utils.isTouchingBase(runner1.x, runner1.y) local touchedBase = utils.isTouchingBase(runner.x, runner.y)
if not touchedBase and runner1.nextBase then if not touchedBase and runner.nextBase then
local distToNext = utils.distanceBetween(runner1.x, runner1.y, runner1.nextBase.x, runner1.nextBase.y) local distToNext = utils.distanceBetween(runner.x, runner.y, runner.nextBase.x, runner.nextBase.y)
local distToPrev = utils.distanceBetween(runner1.x, runner1.y, runner1.prevBase.x, runner1.prevBase.y) local distToPrev = utils.distanceBetween(runner.x, runner.y, runner.prevBase.x, runner.prevBase.y)
if distToNext < distToPrev then if distToNext < distToPrev or distanceFromBall > 350 then
return baseRunningSpeed return baseRunningSpeed
else else
return -1 * baseRunningSpeed return -1 * baseRunningSpeed
@ -124,12 +123,15 @@ end
local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall) local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall)
local targetX, targetY = getNextOutTarget(runners) local targetX, targetY = getNextOutTarget(runners)
if targetX ~= nil and targetY ~= nil then if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
return grabCandidate.catchEligible
end)
nearestFielder.target = utils.xy(targetX, targetY) nearestFielder.target = utils.xy(targetX, targetY)
if nearestFielder == fielder then if nearestFielder == fielder then
ball.heldBy = fielder ball.heldBy = fielder
else else
launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true) launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
Fielding.markIneligible(nearestFielder)
end end
end end
end end