From 371536171892288083638b26bce5d10ee48f2c4e Mon Sep 17 00:00:00 2001
From: Sage Vaillancourt <sage@sagev.space>
Date: Sun, 23 Feb 2025 11:10:40 -0500
Subject: [PATCH] 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.
---
 src/assets.lua               |  8 ++---
 src/baserunning.lua          | 12 +++----
 src/fielding.lua             | 68 +++++++++++++++++++++++-------------
 src/main.lua                 | 17 ++++++---
 src/npc.lua                  |  2 +-
 src/test/mocks.lua           |  9 +++++
 src/test/setup.lua           |  2 ++
 src/test/testBaserunning.lua |  2 +-
 src/test/testFielding.lua    | 49 +++++++++++++++-----------
 9 files changed, 106 insertions(+), 63 deletions(-)

diff --git a/src/assets.lua b/src/assets.lua
index 83176d3..57d659f 100644
--- a/src/assets.lua
+++ b/src/assets.lua
@@ -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
diff --git a/src/baserunning.lua b/src/baserunning.lua
index 88d30a8..7e91680 100644
--- a/src/baserunning.lua
+++ b/src/baserunning.lua
@@ -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
diff --git a/src/fielding.lua b/src/fielding.lua
index 47c4e02..716b172 100644
--- a/src/fielding.lua
+++ b/src/fielding.lua
@@ -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
diff --git a/src/main.lua b/src/main.lua
index 0ec8bfb..e63349e 100644
--- a/src/main.lua
+++ b/src/main.lua
@@ -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
diff --git a/src/npc.lua b/src/npc.lua
index 921c4a4..4bb1af9 100644
--- a/src/npc.lua
+++ b/src/npc.lua
@@ -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
diff --git a/src/test/mocks.lua b/src/test/mocks.lua
index 08d69bd..30b5ee1 100644
--- a/src/test/mocks.lua
+++ b/src/test/mocks.lua
@@ -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()
diff --git a/src/test/setup.lua b/src/test/setup.lua
index 70de319..a66116b 100644
--- a/src/test/setup.lua
+++ b/src/test/setup.lua
@@ -9,3 +9,5 @@ playdate, announcer = mocks[1], mocks[2]
 
 local _f = require("fielding")
 Fielding, newFielder = _f[1], _f[2]
+
+HomeTeamSpriteGroup = {}
diff --git a/src/test/testBaserunning.lua b/src/test/testBaserunning.lua
index d94fc02..d2d2692 100644
--- a/src/test/testBaserunning.lua
+++ b/src/test/testBaserunning.lua
@@ -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
diff --git a/src/test/testFielding.lua b/src/test/testFielding.lua
index 73c719e..1a94f32 100644
--- a/src/test/testFielding.lua
+++ b/src/test/testFielding.lua
@@ -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())