Move fielder targeting to array-based system

Allows for *light* path-finding, but is currently liable to totally lose track of weirdly-hit balls.
BUT this may be more of an issue of not correctly parsing the ball's state (home run, foul ball, etc.
Bat is now white with a black outline.
Some linting.
Run tests with `-v`
playdate.timer.new in mocks.lua
Add test for ball-catchability.
This commit is contained in:
Sage Vaillancourt 2025-02-23 11:10:40 -05:00
parent 7525daccb6
commit 3715361718
9 changed files with 106 additions and 63 deletions

View File

@ -39,11 +39,11 @@ GameLogo = playdate.graphics.image.new("images/game/GameLogo.png")
-- luacheck: ignore
---@type pd_image
Glove = playdate.graphics.image.new("images/game/Glove.png")
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
-- luacheck: ignore
---@type pd_image
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
Glove = playdate.graphics.image.new("images/game/Glove.png")
-- luacheck: ignore
---@type pd_image
@ -113,11 +113,11 @@ BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
-- luacheck: ignore
---@type pd_sampleplayer
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
-- luacheck: ignore
---@type pd_sampleplayer
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
-- luacheck: ignore
---@type pd_sampleplayer

View File

@ -53,9 +53,8 @@ function Baserunning:outRunner(runner, message)
end
end
end
if type(runner) ~= "number" then
error("Expected runner to have type 'number', but was: " .. type(runner))
end
local runnerType = type(runner)
assert(runnerType == "number", "Expected runner to have type 'number', but was: " .. runnerType)
self.outRunners[#self.outRunners + 1] = self.runners[runner]
table.remove(self.runners, runner)
@ -229,8 +228,8 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, isAutoRun,
local autoRun = 0
if not isAutoRun then
autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
or nearestBaseDistance < 5 and 0
or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
or nearestBaseDistance < 5 and 0
or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
end
mult = autoRun + (appliedSpeed / 20)
@ -257,7 +256,8 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun,
if speedIsFunction then
speed = appliedSpeed(runner)
end
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, isAutoRun, deltaSeconds)
local thisRunnerMoved, thisRunnerScored =
self:updateRunner(runner, runnerIndex, speed, isAutoRun, deltaSeconds)
runnersStillMoving = runnersStillMoving or thisRunnerMoved
if thisRunnerScored then
runnersScored = runnersScored + 1

View File

@ -1,7 +1,8 @@
--- @class Fielder {
--- @field name string
--- @field x number
--- @field y number
--- @field target XyPair | nil
--- @field targets XyPair[]
--- @field speed number
--- @field spriteIndex number
@ -56,12 +57,12 @@ end
--- Actually only benches the infield, because outfielders are far away!
---@param position XyPair
function Fielding:benchTo(position)
self.fielders.first.target = position
self.fielders.second.target = position
self.fielders.shortstop.target = position
self.fielders.third.target = position
self.fielders.pitcher.target = position
self.fielders.catcher.target = position
self.fielders.first.targets = { position }
self.fielders.second.targets = { position }
self.fielders.shortstop.targets = { position }
self.fielders.third.targets = { position }
self.fielders.pitcher.targets = { position }
self.fielders.catcher.targets = { position }
end
--- Resets the target positions of all fielders to their defaults (at their field positions).
@ -74,15 +75,15 @@ function Fielding:resetFielderPositions(fromOffTheField)
end
end
self.fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48)
self.fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30)
self.fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30)
self.fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48)
self.fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y)
self.fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92)
self.fielders.left.target = utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1)
self.fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4)
self.fielders.right.target = utils.xy(C.Screen.W * 1.6, self.fielders.left.target.y)
self.fielders.first.targets = { utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) }
self.fielders.second.targets = { utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) }
self.fielders.shortstop.targets = { utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) }
self.fielders.third.targets = { utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) }
self.fielders.pitcher.targets = { utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y) }
self.fielders.catcher.targets = { utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) }
self.fielders.left.targets = { utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1) }
self.fielders.center.targets = { utils.xy(C.Center.x, C.Screen.H * -0.4) }
self.fielders.right.targets = { utils.xy(C.Screen.W * 1.6, self.fielders.left.targets[1].y) }
end
---@param deltaSeconds number
@ -90,14 +91,31 @@ end
---@param ball Ball
---@return boolean canCatch
local function updateFielderPosition(deltaSeconds, fielder, ball)
if fielder.target ~= nil then
if
utils.pointIsSquarelyAboveLine(fielder, C.BottomOfOutfieldWall)
or not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target)
then
fielder.target = nil
if #fielder.targets > 0 then
local nextFielderPos = utils.xy(fielder.x, fielder.y)
local currentTarget = fielder.targets[#fielder.targets]
local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget)
if willMove and utils.pointIsSquarelyAboveLine(nextFielderPos, C.BottomOfOutfieldWall) then
local targetCount = #fielder.targets
-- Back up a little
fielder.targets[targetCount + 2] = utils.xy(fielder.x, fielder.y + 5)
-- Try to come at it from below
fielder.targets[targetCount + 1] = utils.xy(currentTarget.x, fielder.y + 10)
end
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.targets[#fielder.targets]) then
table.remove(fielder.targets, #fielder.targets)
end
end
-- TODO: Clean this up, like, a lot.
-- I'd love to avoid any "real" pathfinding implementation, but these huge target queues are liable to be an issue.
-- The worst case came when a ball was hit far, but not in a way that the game classed as a home run.
-- Maybe this queueing would be fine if that issue was resolved
if #fielder.targets >= 10 then
fielder.targets = { utils.xy(fielder.x, fielder.y + 100) }
end
assert(#fielder.targets < 10, "Fielder " .. fielder.name .. " is accruing too many target positions!")
return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox
end
@ -111,7 +129,7 @@ end
---@param ballDestY number
function Fielding:haveSomeoneChase(ballDestX, ballDestY)
local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY)
chasingFielder.target = utils.xy(ballDestX, ballDestY)
chasingFielder.targets = { utils.xy(ballDestX, ballDestY) }
for _, base in ipairs(C.Bases) do
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
@ -121,7 +139,7 @@ function Fielding:haveSomeoneChase(ballDestX, ballDestY)
end
return fielder ~= chasingFielder
end)
nearest.target = base
nearest.targets = { base }
end
end
@ -160,7 +178,7 @@ local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end)
closestFielder.target = targetBase
closestFielder.targets = { targetBase }
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
return

View File

@ -51,6 +51,7 @@ import 'draw/transitions.lua'
-- TODO: Customizable field structure. E.g. stands and ads etc.
---@type pd_graphics_lib
local gfx <const>, C <const> = playdate.graphics, C
---@alias Team { benchPosition: XyPair }
@ -357,6 +358,9 @@ function Game:updateBatting(batDeg, batSpeed)
if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then
self.state.didSwing = true
end
-- TODO? Make the bat angle work more like the throw meter.
-- Would instead constantly drift toward a default value, giving us a little more control,
-- 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
@ -390,10 +394,8 @@ function Game:updateBatting(batDeg, batSpeed)
ballVelY = ballVelY * -1
end
local ballDest = utils.xy(
self.state.ball.x + (ballVelX * C.BattingPower),
self.state.ball.y + (ballVelY * C.BattingPower)
)
local ballDest =
utils.xy(self.state.ball.x + (ballVelX * C.BattingPower), self.state.ball.y + (ballVelY * C.BattingPower))
pitchTracker:reset()
local flyTimeMs = 2000
@ -665,8 +667,13 @@ function Game:update()
end
if self.state.offenseState == C.Offense.batting then
gfx.setLineWidth(5)
gfx.setLineWidth(7)
gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y)
gfx.setColor(gfx.kColorWhite)
gfx.setLineCapStyle(gfx.kLineCapStyleRound)
gfx.setLineWidth(3)
gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y)
gfx.setColor(gfx.kColorBlack)
end
for _, runner in pairs(self.baserunning.outRunners) do

View File

@ -126,7 +126,7 @@ local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners)
if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
nearestFielder.target = utils.xy(targetX, targetY)
nearestFielder.targets = { utils.xy(targetX, targetY) }
if nearestFielder == fielder then
ball.heldBy = fielder
else

View File

@ -2,6 +2,15 @@ utils = require("utils")
local mockPlaydate = {
TEST_MODE = true,
timer = {
new = function(_, callback)
return {
mockCompletion = function()
callback()
end,
}
end,
},
graphics = {
animator = {
new = function()

View File

@ -9,3 +9,5 @@ playdate, announcer = mocks[1], mocks[2]
local _f = require("fielding")
Fielding, newFielder = _f[1], _f[2]
HomeTeamSpriteGroup = {}

View File

@ -29,7 +29,7 @@ function buildRunnersOn(runnerLocations)
for b = 1, location do
runner.x = C.Bases[b].x
runner.y = C.Bases[b].y
baserunning:updateNonBatterRunners(0.001, false, 0.001)
baserunning:updateNonBatterRunners(0.001, false, false, 0.001)
end
else
-- Is a raw XyPair

View File

@ -1,47 +1,54 @@
require("test/setup")
require("ball")
---@return Fielding, number fielderCount
---@return Fielding, Fielder someBaseman
local function fieldersAtDefaultPositions()
local fielding = Fielding.new()
fielding:resetFielderPositions()
local fielderCount = 0
for _, fielder in pairs(fielding.fielders) do
fielder.x = fielder.target.x
fielder.y = fielder.target.y
fielder.x = fielder.targets[#fielder.targets].x
fielder.y = fielder.targets[#fielder.targets].y
fielderCount = fielderCount + 1
end
return fielding, fielderCount
return fielding, fielding.fielders.second
end
---@param x number
---@param y number
---@param z number | nil
function fakeBall(x, y, z)
return {
x = x,
y = y,
z = z or 0,
heldBy = nil,
}
local function ballAt(x, y, z)
local ball = Ball.new(playdate.graphics.animator)
ball.x = x
ball.y = y
ball.z = z
return ball
end
function testBallPickedUpByNearbyFielders()
local fielding, fielderCount = fieldersAtDefaultPositions()
luaunit.assertIs("table", type(fielding))
luaunit.assertIs("table", type(fielding.fielders))
luaunit.assertEquals(9, fielderCount)
local fielding, baseman = fieldersAtDefaultPositions()
local ball = ballAt(baseman.x, baseman.y, baseman.z)
local ball = fakeBall(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIsNil(ball.heldBy, "Ball should not be held by a fielder yet")
luaunit.assertIs(baseman, ball.heldBy, "Ball should be held by the nearest fielder")
end
local secondBaseman = fielding.fielders.second
ball.x = secondBaseman.x
ball.y = secondBaseman.y
function testBallNotPickedUpByDistantFielders()
local fielding = fieldersAtDefaultPositions()
local ball = ballAt(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIs(secondBaseman, ball.heldBy, "Ball should be held by the nearest fielder")
luaunit.assertIsNil(ball.heldBy, "Ball should be too far for any fielders to pick up")
end
function testBallNotPickedUpWhenNotCatchable()
local fielding, baseman = fieldersAtDefaultPositions()
local ball = ballAt(baseman.x, baseman.y, baseman.z)
ball:markUncatchable()
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIsNil(ball.heldBy, "Ball should be held by the nearest fielder")
end
os.exit(luaunit.LuaUnit.run())