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 -- luacheck: ignore
---@type pd_image ---@type pd_image
Glove = playdate.graphics.image.new("images/game/Glove.png") GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
-- luacheck: ignore -- luacheck: ignore
---@type pd_image ---@type pd_image
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png") Glove = playdate.graphics.image.new("images/game/Glove.png")
-- luacheck: ignore -- luacheck: ignore
---@type pd_image ---@type pd_image
@ -113,11 +113,11 @@ BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
-- luacheck: ignore -- luacheck: ignore
---@type pd_sampleplayer ---@type pd_sampleplayer
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav") BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
-- luacheck: ignore -- luacheck: ignore
---@type pd_sampleplayer ---@type pd_sampleplayer
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav") BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
-- luacheck: ignore -- luacheck: ignore
---@type pd_sampleplayer ---@type pd_sampleplayer

View File

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

View File

@ -1,7 +1,8 @@
--- @class Fielder { --- @class Fielder {
--- @field name string
--- @field x number --- @field x number
--- @field y number --- @field y number
--- @field target XyPair | nil --- @field targets XyPair[]
--- @field speed number --- @field speed number
--- @field spriteIndex number --- @field spriteIndex number
@ -56,12 +57,12 @@ end
--- Actually only benches the infield, because outfielders are far away! --- Actually only benches the infield, because outfielders are far away!
---@param position XyPair ---@param position XyPair
function Fielding:benchTo(position) function Fielding:benchTo(position)
self.fielders.first.target = position self.fielders.first.targets = { position }
self.fielders.second.target = position self.fielders.second.targets = { position }
self.fielders.shortstop.target = position self.fielders.shortstop.targets = { position }
self.fielders.third.target = position self.fielders.third.targets = { position }
self.fielders.pitcher.target = position self.fielders.pitcher.targets = { position }
self.fielders.catcher.target = position self.fielders.catcher.targets = { position }
end end
--- Resets the target positions of all fielders to their defaults (at their field positions). --- Resets the target positions of all fielders to their defaults (at their field positions).
@ -74,15 +75,15 @@ function Fielding:resetFielderPositions(fromOffTheField)
end end
end end
self.fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) self.fielders.first.targets = { 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.second.targets = { 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.shortstop.targets = { 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.third.targets = { 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.pitcher.targets = { 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.catcher.targets = { 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.left.targets = { 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.center.targets = { 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.right.targets = { utils.xy(C.Screen.W * 1.6, self.fielders.left.targets[1].y) }
end end
---@param deltaSeconds number ---@param deltaSeconds number
@ -90,14 +91,31 @@ end
---@param ball Ball ---@param ball Ball
---@return boolean canCatch ---@return boolean canCatch
local function updateFielderPosition(deltaSeconds, fielder, ball) local function updateFielderPosition(deltaSeconds, fielder, ball)
if fielder.target ~= nil then if #fielder.targets > 0 then
if local nextFielderPos = utils.xy(fielder.x, fielder.y)
utils.pointIsSquarelyAboveLine(fielder, C.BottomOfOutfieldWall) local currentTarget = fielder.targets[#fielder.targets]
or not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget)
then
fielder.target = nil 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
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 return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox
end end
@ -111,7 +129,7 @@ end
---@param ballDestY number ---@param ballDestY number
function Fielding:haveSomeoneChase(ballDestX, ballDestY) function Fielding:haveSomeoneChase(ballDestX, ballDestY)
local chasingFielder = utils.getNearestOf(self.fielders, 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 for _, base in ipairs(C.Bases) do
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder) local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
@ -121,7 +139,7 @@ function Fielding:haveSomeoneChase(ballDestX, ballDestY)
end end
return fielder ~= chasingFielder return fielder ~= chasingFielder
end) end)
nearest.target = base nearest.targets = { base }
end end
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 return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end) end)
closestFielder.target = targetBase closestFielder.targets = { targetBase }
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
return return

View File

@ -51,6 +51,7 @@ import 'draw/transitions.lua'
-- TODO: Customizable field structure. E.g. stands and ads etc. -- TODO: Customizable field structure. E.g. stands and ads etc.
---@type pd_graphics_lib
local gfx <const>, C <const> = playdate.graphics, C local gfx <const>, C <const> = playdate.graphics, C
---@alias Team { benchPosition: XyPair } ---@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 if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then
self.state.didSwing = true self.state.didSwing = true
end 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) local batAngle = math.rad(batDeg)
-- TODO: animate bat-flip or something -- TODO: animate bat-flip or something
self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0 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 ballVelY = ballVelY * -1
end end
local ballDest = utils.xy( local ballDest =
self.state.ball.x + (ballVelX * C.BattingPower), utils.xy(self.state.ball.x + (ballVelX * C.BattingPower), self.state.ball.y + (ballVelY * C.BattingPower))
self.state.ball.y + (ballVelY * C.BattingPower)
)
pitchTracker:reset() pitchTracker:reset()
local flyTimeMs = 2000 local flyTimeMs = 2000
@ -665,8 +667,13 @@ function Game:update()
end end
if self.state.offenseState == C.Offense.batting then 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.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 end
for _, runner in pairs(self.baserunning.outRunners) do 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) 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)
nearestFielder.target = utils.xy(targetX, targetY) nearestFielder.targets = { utils.xy(targetX, targetY) }
if nearestFielder == fielder then if nearestFielder == fielder then
ball.heldBy = fielder ball.heldBy = fielder
else else

View File

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

View File

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

View File

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

View File

@ -1,47 +1,54 @@
require("test/setup") require("test/setup")
require("ball")
---@return Fielding, number fielderCount ---@return Fielding, Fielder someBaseman
local function fieldersAtDefaultPositions() local function fieldersAtDefaultPositions()
local fielding = Fielding.new() local fielding = Fielding.new()
fielding:resetFielderPositions() fielding:resetFielderPositions()
local fielderCount = 0 local fielderCount = 0
for _, fielder in pairs(fielding.fielders) do for _, fielder in pairs(fielding.fielders) do
fielder.x = fielder.target.x fielder.x = fielder.targets[#fielder.targets].x
fielder.y = fielder.target.y fielder.y = fielder.targets[#fielder.targets].y
fielderCount = fielderCount + 1 fielderCount = fielderCount + 1
end end
return fielding, fielderCount return fielding, fielding.fielders.second
end end
---@param x number ---@param x number
---@param y number ---@param y number
---@param z number | nil ---@param z number | nil
function fakeBall(x, y, z) local function ballAt(x, y, z)
return { local ball = Ball.new(playdate.graphics.animator)
x = x, ball.x = x
y = y, ball.y = y
z = z or 0, ball.z = z
heldBy = nil, return ball
}
end end
function testBallPickedUpByNearbyFielders() function testBallPickedUpByNearbyFielders()
local fielding, fielderCount = fieldersAtDefaultPositions() local fielding, baseman = fieldersAtDefaultPositions()
luaunit.assertIs("table", type(fielding)) local ball = ballAt(baseman.x, baseman.y, baseman.z)
luaunit.assertIs("table", type(fielding.fielders))
luaunit.assertEquals(9, fielderCount)
local ball = fakeBall(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01) 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 function testBallNotPickedUpByDistantFielders()
ball.x = secondBaseman.x local fielding = fieldersAtDefaultPositions()
ball.y = secondBaseman.y local ball = ballAt(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01) 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 end
os.exit(luaunit.LuaUnit.run()) os.exit(luaunit.LuaUnit.run())