Compare commits

...

3 Commits

Author SHA1 Message Date
Sage Vaillancourt 027bb31bff A bit of testing for baserunning and fielding.
Required minor reworking to get those test harnesses in place.
2025-02-12 22:49:37 -05:00
Sage Vaillancourt a801b64f55 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
2025-02-12 17:18:18 -05:00
Sage Vaillancourt 1926960c86 NPCs can play each other
Some timing tweaks and TODOs.
Cluster global state tighter together.
2025-02-11 13:50:03 -05:00
12 changed files with 448 additions and 117 deletions

View File

@ -23,7 +23,7 @@ function Announcer.new()
}, { __index = Announcer })
end
local DurationMs <const> = 3000
local DurationMs <const> = 2000
function Announcer:popIn()
self.animatorY = AnnouncerAnimatorInY

View File

@ -1,6 +1,5 @@
-- selene: allow(unscoped_variables)
---@class Ball
---@field private animator pd_animator_lib
---@field x number
---@field y number
---@field z number
@ -10,13 +9,15 @@
---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator
---@field floatAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib
---@field private flyTimeMs number
Ball = {}
---@param animator pd_animator_lib
---@param animatorLib pd_animator_lib
---@return Ball
function Ball.new(animator)
function Ball.new(animatorLib)
return setmetatable({
animator = animator,
animatorLib = animatorLib,
x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]],
z = 0,
@ -29,10 +30,26 @@ function Ball.new(animator)
-- TODO? Replace these with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
floatAnimator = animator.new(2000, -60, 0, utils.easingHill),
floatAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
}, { __index = Ball })
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.
---@param destX 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
end
self.flyTimeMs = flyTimeMs
if customBallScaler then
self.sizeAnimator = customBallScaler
else
-- 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
self.yAnimator = self.animator.new(flyTimeMs, self.y, destY, easingFunc)
self.xAnimator = self.animator.new(flyTimeMs, self.x, destX, easingFunc)
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
if floaty then
self.floatAnimator:reset(flyTimeMs)
end

View File

@ -17,7 +17,9 @@
---@field onThirdOut fun()
Baserunning = {}
---@param announcer any
-- TODO: Implement slides. Would require making fielders' gloves "real objects" whose state is tracked.
---@param announcer Announcer
---@return Baserunning
function Baserunning.new(announcer, onThirdOut)
local o = setmetatable({
@ -32,7 +34,7 @@ function Baserunning.new(announcer, onThirdOut)
onThirdOut = onThirdOut,
}, { __index = Baserunning })
o.batter = o:newRunner()
o:pushNewBatter()
return o
end
@ -77,6 +79,7 @@ end
function Baserunning:outEligibleRunners(fielder)
local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
local didOutRunner = false
for i, runner in pairs(self.runners) do
local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
if -- Force out
@ -114,6 +117,14 @@ function Baserunning:updateForcedRunners()
end
end
function Baserunning:convertBatterToRunner()
self.batter.nextBase = C.Bases[C.First]
self.batter.prevBase = C.Bases[C.Home]
self:updateForcedRunners()
self.batter.forcedTo = C.Bases[C.First]
self.batter = nil -- Demote batter to a mere runner
end
---@param deltaSeconds number
function Baserunning:walkAwayOutRunners(deltaSeconds)
for i, runner in ipairs(self.outRunners) do
@ -127,7 +138,7 @@ function Baserunning:walkAwayOutRunners(deltaSeconds)
end
---@return Runner
function Baserunning:newRunner()
function Baserunning:pushNewBatter()
local new = {
-- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol.
x = C.RightHandedBattersBox.x - 60,
@ -137,6 +148,7 @@ function Baserunning:newRunner()
forcedTo = C.Bases[C.First],
}
self.runners[#self.runners + 1] = new
self.batter = new
return new
end
@ -179,8 +191,10 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSecond
-- TODO: Do a better job drifting runners toward their bases when appliedSpeed is low/zero
if distance < 2 then
if runner.prevBase ~= nearestBase then
runner.prevBase = runner.nextBase
runner.nextBase = C.NextBaseMap[runner.nextBase]
end
runner.forcedTo = nil
return false, false
end
@ -196,6 +210,9 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSecond
end
end
-- TODO: Make this less "sticky" for the user.
-- Currently it can be a little hard to run *past* a base.
local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
or nearestBaseDistance < 5 and 0
or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
@ -209,16 +226,21 @@ end
--- Update non-batter runners.
--- 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
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
local someRunnerMoved = false
local runnersScored = 0
local speedIsFunction = type(appliedSpeed) == "function"
-- TODO: Filter for the runner closest to the currently-held direction button
for runnerIndex, runner in ipairs(self.runners) do
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
if thisRunnerScored then
runnersScored = runnersScored + 1
@ -232,3 +254,7 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon
return someRunnerMoved, runnersScored
end
if not playdate or playdate.TEST_MODE then
return Baserunning
end

View File

@ -2,8 +2,8 @@
C = {}
C.Screen = {
W = playdate.display.getWidth(),
H = playdate.display.getHeight(),
W = playdate and playdate.display.getWidth() or 400,
H = playdate and playdate.display.getHeight() or 240,
}
C.Center = utils.xy(C.Screen.W / 2, C.Screen.H / 2)
@ -86,6 +86,7 @@ C.BaseHitbox = 10
C.BattingPower = 25
C.BatterHandPos = utils.xy(25, 15)
C.GloveZ = 0 -- 10
C.SmallestBallRadius = 6
@ -130,3 +131,7 @@ C.UserThrowPower = 0.3
C.WalkedRunnerSpeed = 10
C.ResetFieldersAfterSeconds = 2.5
if not playdate then
return C
end

View File

@ -21,9 +21,9 @@ end
-- Only works if called with the bases empty (i.e. the only runner should be the batter.
-- selene: allow(unused_variable)
function dbg.loadTheBases(br)
br:newRunner()
br:newRunner()
br:newRunner()
br:pushNewBatter()
br:pushNewBatter()
br:pushNewBatter()
br.runners[2].x = C.Bases[C.First].x
br.runners[2].y = C.Bases[C.First].y

View File

@ -1,14 +1,16 @@
--- @alias Fielder {
--- x: number,
--- y: number,
--- target: XyPair | nil,
--- speed: number,
--- }
--- @class Fielder {
--- @field catchEligible boolean
--- @field x number
--- @field y number
--- @field target XyPair | nil
--- @field speed number
-- TODO: Run down baserunners in a pickle.
-- selene: allow(unscoped_variables)
---@class Fielding
---@field fielders table<string, Fielder>
---@field fielderTouchingBall Fielder | nil
---@field fielderHoldingBall Fielder | nil
Fielding = {}
local FielderDanceAnimator <const> = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
@ -21,6 +23,7 @@ local function newFielder(name, speed)
return {
name = name,
speed = speed * C.FielderRunMult,
catchEligible = true,
}
end
@ -38,7 +41,7 @@ function Fielding.new()
right = newFielder("Right", 50),
},
---@type Fielder | nil
fielderTouchingBall = nil,
fielderHoldingBall = nil,
}, { __index = Fielding })
end
@ -77,7 +80,7 @@ end
---@param deltaSeconds number
---@param fielder Fielder
---@param ballPos XyPair
---@return boolean isTouchingBall
---@return boolean inCatchingRange
local function updateFielderPosition(deltaSeconds, fielder, ballPos)
if fielder.target ~= nil then
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
@ -106,37 +109,62 @@ function Fielding:haveSomeoneChase(ballDestX, ballDestY)
end
end
---@param ball XyPair
---@param ball Ball
---@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)
local fielderTouchingBall = nil
local fielderHoldingBall = nil
for _, fielder in pairs(self.fielders) do
local isTouchingBall = updateFielderPosition(deltaSeconds, fielder, ball)
if isTouchingBall then
fielderTouchingBall = fielder
local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball)
if inCatchingRange and fielder.catchEligible then
-- TODO: Base this catch on fielder skill?
fielderHoldingBall = fielder
ball.heldBy = fielder -- How much havoc will this wreak?
end
end
self.fielderTouchingBall = fielderTouchingBall
return fielderTouchingBall
-- 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.
-- Right now, a line-drive *through* first will be counted as an out.
self.fielderHoldingBall = fielderHoldingBall
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
-- TODO? Start moving target fielders close sooner?
---@param field table
---@param field Fielding
---@param targetBase Base
---@param launchBall LaunchBall
---@param throwFlyMs number
---@return ActionResult
local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs)
if field.fielderTouchingBall == nil then
if field.fielderHoldingBall == nil then
return ActionResult.NeedsMoreTime
end
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)
closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return ActionResult.Succeeded
end
@ -167,3 +195,7 @@ function Fielding:drawFielders(fielderSprites, ball)
end
return ballIsHeld
end
if not playdate or playdate.TEST_MODE then
return { Fielding, newFielder }
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -42,15 +42,23 @@ local gfx <const>, C <const> = playdate.graphics, C
local announcer = Announcer.new()
local fielding = Fielding.new()
-- TODO: Find a way to get baserunning and npc instantiated closer to the top, here.
-- Currently difficult because they depend on nextHalfInning/each other.
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
------------------
-- GLOBAL STATE --
------------------
local deltaSeconds = 0
local ball = Ball.new(gfx.animator)
---@alias Team { score: number, benchPosition: XyPair }
local batBase <const> = utils.xy(C.Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)
local batAngleDeg = C.CrankOffsetDeg
local catcherThrownBall = false
---@alias Team { score: number, benchPosition: XyPair }
---@type table<string, Team>
local teams <const> = {
home = {
@ -63,25 +71,28 @@ local teams <const> = {
},
}
local UserTeam <const> = teams.away
local battingTeam = teams.away
local battingTeamSprites = AwayTeamSprites
local fieldingTeamSprites = HomeTeamSprites
local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
local inning = 1
local battingTeam = teams.away
local offenseState = C.Offense.batting
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
local secondsSinceLastRunnerMove = 0
local secondsSincePitchAllowed = -5
local secondsSincePitchAllowed = 0
local catcherThrownBall = false
-- These are only sort-of global state. They are purely graphical,
-- but they need to be kept in sync with the rest of the globals.
local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
local battingTeamSprites = AwayTeamSprites
local fieldingTeamSprites = HomeTeamSprites
local batBase <const> = utils.xy(C.Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)
-------------------------
-- END OF GLOBAL STATE --
-------------------------
local batAngleDeg = C.CrankOffsetDeg
local UserTeam <const> = teams.away
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
---@type Pitch[]
@ -113,8 +124,12 @@ local Pitches <const> = {
},
}
---@return boolean userIsOnSide, boolean playerIsOnOtherSide
---@return boolean userIsOnSide, boolean userIsOnOtherSide
local function userIsOn(side)
if UserTeam == nil then
-- Both teams are NPC-driven
return false, false
end
local ret
if UserTeam == battingTeam then
ret = side == C.Sides.offense
@ -124,13 +139,7 @@ local function userIsOn(side)
return ret, not ret
end
--- Launches the ball from its current position to the given destination.
---@param destX number
---@param destY number
---@param easingFunc EasingFunc
---@param flyTimeMs number | nil
---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil
---@type LaunchBall
local function launchBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
throwMeter:reset()
ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
@ -139,6 +148,8 @@ end
---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil
local function pitch(pitchFlyTimeMs, pitchTypeIndex)
Fielding.markIneligible(fielding.fielders.pitcher)
ball.heldBy = nil
catcherThrownBall = false
offenseState = C.Offense.batting
@ -174,10 +185,15 @@ local function nextHalfInning()
announcer:say("SWITCHING SIDES...")
end
-- TODO: Make the overlay handle its own dang delay.
-- Delay to keep end-of-inning on the scoreboard for a few seconds
playdate.timer.new(3000, function()
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
else
fielding:resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
battingTeam = currentlyFieldingTeam
playdate.timer.new(2000, function()
if battingTeam == teams.home then
battingTeamSprites = HomeTeamSprites
runnerBlipper = HomeTeamBlipper
@ -187,15 +203,8 @@ local function nextHalfInning()
fieldingTeamSprites = HomeTeamSprites
runnerBlipper = AwayTeamBlipper
end
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
else
fielding:resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
end
end)
end
end
local baserunning = Baserunning.new(announcer, nextHalfInning)
@ -239,13 +248,14 @@ local function nextBatter()
playdate.timer.new(2000, function()
pitchTracker:reset()
if not baserunning.batter then
baserunning.batter = baserunning:newRunner()
baserunning:pushNewBatter()
end
end)
end
local function walk()
announcer:say("Walk!")
-- TODO? Use baserunning:convertBatterToRunner()
baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home]
offenseState = C.Offense.walking
@ -301,17 +311,13 @@ local function updateBatting(batDeg, batSpeed)
return
end
baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home]
baserunning:updateForcedRunners()
baserunning.batter.forcedTo = C.Bases[C.First]
baserunning.batter = nil -- Demote batter to a mere runner
baserunning:convertBatterToRunner()
fielding:haveSomeoneChase(ballDestX, ballDestY)
end
end
---@param appliedSpeed number
---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved
local function updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
@ -344,16 +350,7 @@ local function updateGameState()
crankLimited = crankLimited * -1
end
if ball.heldBy then
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
ball:updatePosition()
local userOnOffense, userOnDefense = userIsOn(C.Sides.offense)
@ -411,7 +408,10 @@ local function updateGameState()
end
end
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
secondsSinceLastRunnerMove = 0
else
@ -419,11 +419,12 @@ local function updateGameState()
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher
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()
offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter()
if not baserunning.batter then
baserunning.batter = baserunning:newRunner()
baserunning:pushNewBatter()
end
end
end
@ -443,8 +444,8 @@ local function updateGameState()
end
if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
if userOnOffense then
npc:fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, launchBall)
if not userOnDefense and offenseState == C.Offense.running then
npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall)
end
end
@ -452,6 +453,8 @@ local function updateGameState()
actionQueue:runWaiting(deltaSeconds)
end
-- TODO: Swappable update() for main menu, etc.
function playdate.update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()

View File

@ -39,26 +39,25 @@ end
local baseRunningSpeed = 25
--- TODO: Individual runner control.
---@param runner Runner
---@param ball Point3d
---@return number
function Npc:runningSpeed(ball)
function Npc:runningSpeed(runner, ball)
if #self.runners == 0 then
return 0
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) > 250
if ballIsFar or runner1.forcedTo then
if distanceFromBall > 400 or runner.forcedTo then
return baseRunningSpeed
end
local touchedBase = utils.isTouchingBase(runner1.x, runner1.y)
if not touchedBase and runner1.nextBase then
local distToNext = utils.distanceBetween(runner1.x, runner1.y, runner1.nextBase.x, runner1.nextBase.y)
local distToPrev = utils.distanceBetween(runner1.x, runner1.y, runner1.prevBase.x, runner1.prevBase.y)
if distToNext < distToPrev then
local touchedBase = utils.isTouchingBase(runner.x, runner.y)
if not touchedBase and runner.nextBase then
local distToNext = utils.distanceBetween(runner.x, runner.y, runner.nextBase.x, runner.nextBase.y)
local distToPrev = utils.distanceBetween(runner.x, runner.y, runner.prevBase.x, runner.prevBase.y)
if distToNext < distToPrev or distanceFromBall > 350 then
return baseRunningSpeed
else
return -1 * baseRunningSpeed
@ -124,25 +123,24 @@ end
local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall)
local targetX, targetY = getNextOutTarget(runners)
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)
if nearestFielder == fielder then
ball.heldBy = fielder
else
launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
Fielding.markIneligible(nearestFielder)
end
end
end
---@param offenseState OffenseState
---@param fielder Fielder
---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil }
---@param launchBall LaunchBall
function Npc:fielderAction(offenseState, fielder, outedSomeRunner, ball, launchBall)
if offenseState ~= C.Offense.running then
return
end
function Npc:fielderAction(fielder, outedSomeRunner, ball, launchBall)
if outedSomeRunner then
-- Delay a little before the next play
playdate.timer.new(750, function()

21
src/test/mocks.lua Normal file
View File

@ -0,0 +1,21 @@
utils = require("utils")
local mockPlaydate = {
TEST_MODE = true,
graphics = {
animator = {
new = function()
return utils.staticAnimator(0)
end,
},
},
}
---@type Announcer
local mockAnnouncer = {
say = function(self, message)
self.lastMessage = message
end,
}
return { mockPlaydate, mockAnnouncer }

View File

@ -0,0 +1,170 @@
import = function() end
luaunit = require("luaunit")
luaunit.ORDER_ACTUAL_EXPECTED = false
utils = require("utils")
C = require("constants")
mocks = require("test/mocks")
playdate, announcer = mocks[1], mocks[2]
Baserunning = require("baserunning")
local _f = require("fielding")
Fielding, newFielder = _f[1], _f[2]
---@return Baserunning, { called: boolean }
function buildBaserunning()
local thirdOutCallbackData = { called = false }
local baserunning = Baserunning.new(announcer, function()
thirdOutCallbackData.called = true
end)
return baserunning, thirdOutCallbackData
end
---@alias BaseIndexOrXyPair (integer | XyPair)
--- NOTE: in addition to the given runners, there is implicitly a batter running from first.
---@param runnerLocations BaseIndexOrXyPair[]
---@return Baserunning
function buildRunnersOn(runnerLocations)
local baserunning = buildBaserunning()
baserunning:convertBatterToRunner()
for _, location in ipairs(runnerLocations) do
baserunning:pushNewBatter()
local runner = baserunning.batter
baserunning:convertBatterToRunner()
if type(location) == "number" then
-- Is a base index
-- Push the runner *through* each base.
for b = 1, location do
runner.x = C.Bases[b].x
runner.y = C.Bases[b].y
baserunning:updateNonBatterRunners(0.001, false, 0.001)
end
else
-- Is a raw XyPair
runner.x = location.x
runner.y = location.y
end
end
return baserunning
end
---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] }
---@param expected boolean
---@param fielderWithBallAt XyPair
---@param when integer[][]
function assertRunnerOutCondition(expected, when, fielderWithBallAt)
local msg = expected and "out" or "safe"
for _, runnersOn in ipairs(when) do
local baserunning = buildRunnersOn(runnersOn)
local outedSomeRunner = baserunning:outEligibleRunners(fielderWithBallAt)
luaunit.failIf(outedSomeRunner ~= expected, "Runner should have been " .. msg .. ", but was not!")
end
end
---@param condition Condition
function assertRunnerStatuses(condition)
assertRunnerOutCondition(true, condition.outWhen, condition.fielderWithBallAt)
assertRunnerOutCondition(false, condition.safeWhen, condition.fielderWithBallAt)
end
function testForceOutsAtFirst()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.First],
outWhen = {
{},
{ 1 },
{ 2 },
{ 3 },
{ 1, 2 },
{ 1, 3 },
{ 2, 3 },
{ 1, 2, 3 },
},
safeWhen = {},
})
end
function testForceOutsAtSecond()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Second],
outWhen = {
{ 1 },
{ 1, 2 },
{ 1, 3 },
{ 1, 2, 3 },
},
safeWhen = {
{},
{ 2 },
{ 3 },
{ 2, 3 },
},
})
end
function testForceOutsAtThird()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Third],
outWhen = {
{ 1, 2 },
{ 1, 2, 3 },
},
safeWhen = {
{ 1 },
{ 2 },
{ 3 },
{ 2, 3 },
{ 1, 3 },
},
})
end
function testForceOutsAtHome()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Home],
outWhen = {
{ 1, 2, 3 },
},
safeWhen = {
{},
{ 1 },
{ 2 },
{ 3 },
{ 1, 2 },
{ 1, 3 },
{ 2, 3 },
},
})
end
function testTagOutsShouldHappenOffBase()
local fielderWithBallAt = utils.xy(10, 10) -- Some location not on a base.
local farFromFielder = utils.xy(100, 100)
assertRunnerStatuses({
fielderWithBallAt = fielderWithBallAt,
outWhen = {
{ fielderWithBallAt },
},
safeWhen = {
{ farFromFielder },
},
})
end
function testTagOutsShouldNotHappenOnBase()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Third],
outWhen = {},
safeWhen = {
{ 2 },
{ 3 },
{ 2, 3 },
{ 2, 3, 4 },
},
})
end
os.exit(luaunit.LuaUnit.run())

57
src/test/testFielding.lua Normal file
View File

@ -0,0 +1,57 @@
import = function() end
luaunit = require("luaunit")
luaunit.ORDER_ACTUAL_EXPECTED = false
utils = require("utils")
C = require("constants")
mocks = require("test/mocks")
playdate = mocks[1]
_f = require("fielding")
Fielding = _f[1]
---@return Fielding, number fielderCount
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
fielderCount = fielderCount + 1
end
return fielding, fielderCount
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,
}
end
function testBallPickedUpByNearbyFielders()
local fielding, fielderCount = fieldersAtDefaultPositions()
luaunit.assertIs("table", type(fielding))
luaunit.assertIs("table", type(fielding.fielders))
luaunit.assertEquals(9, fielderCount)
local ball = fakeBall(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIsNil(ball.heldBy, "Ball should not be held by a fielder yet")
local secondBaseman = fielding.fielders.second
ball.x = secondBaseman.x
ball.y = secondBaseman.y
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIs(secondBaseman, ball.heldBy, "Ball should be held by the nearest fielder")
end
os.exit(luaunit.LuaUnit.run())