Class-ify Announcer, Baserunning, and Fielding

Largely to enable dependency injection.
I am pushing AWAY the paranoia that metatable lookups will slow things down. (You've got like 20 entities, bud, chill.)
Field -> Fielding
This commit is contained in:
Sage Vaillancourt 2025-02-09 11:19:11 -05:00
parent aadaa6e0d6
commit 9c0d263a29
6 changed files with 102 additions and 72 deletions

View File

@ -11,14 +11,21 @@ local AnnouncerAnimatorOutY <const> =
playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint) playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint)
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)
announcer = { ---@class Announcer
---@field textQueue string[]
---@field animatorY pd_animator
Announcer = {}
function Announcer.new()
return setmetatable({
textQueue = {}, textQueue = {},
animatorY = AnnouncerAnimatorInY, animatorY = AnnouncerAnimatorInY,
} }, { __index = Announcer })
end
local DurationMs <const> = 3000 local DurationMs <const> = 3000
function announcer:popIn() function Announcer:popIn()
self.animatorY = AnnouncerAnimatorInY self.animatorY = AnnouncerAnimatorInY
self.animatorY:reset() self.animatorY:reset()
@ -39,14 +46,14 @@ function announcer:popIn()
end) end)
end end
function announcer:say(text) function Announcer:say(text)
self.textQueue[#self.textQueue + 1] = text self.textQueue[#self.textQueue + 1] = text
if #self.textQueue == 1 then if #self.textQueue == 1 then
self:popIn() self:popIn()
end end
end end
function announcer:draw(x, y) function Announcer:draw(x, y)
if #self.textQueue == 0 then if #self.textQueue == 0 then
return return
end end

View File

@ -7,27 +7,37 @@
--- } --- }
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)
baserunning = { ---@class Baserunning
---@type Runner[] ---@field runners Runner[]
---@field outRunners Runner[]
---@field scoredRunners Runner[]
---@field batter Runner | nil
---@field outs number
---@field announcer Announcer
Baserunning = {}
---@param announcer any
---@return Baserunning
function Baserunning.new(announcer)
local o = setmetatable({
runners = {}, runners = {},
---@type Runner[]
outRunners = {}, outRunners = {},
---@type Runner[]
scoredRunners = {}, scoredRunners = {},
---@type Runner | nil
batter = nil, batter = nil,
--- Since this object is what ultimately *mutates* the out count, --- Since this object is what ultimately *mutates* the out count,
--- it seems sensible to store the value here. --- it seems sensible to store the value here.
outs = 0 outs = 0,
} announcer = announcer
}, { __index = Baserunning })
o.batter = o:newRunner()
return o
end
---@param runner integer | Runner ---@param runner integer | Runner
---@param message string | nil ---@param message string | nil
function baserunning:outRunner(runner, message) function Baserunning:outRunner(runner, message)
self.outs = self.outs + 1 self.outs = self.outs + 1
if type(runner) ~= "number" then if type(runner) ~= "number" then
for i, maybe in ipairs(self.runners) do for i, maybe in ipairs(self.runners) do
@ -44,7 +54,7 @@ function baserunning:outRunner(runner, message)
self:updateForcedRunners() self:updateForcedRunners()
announcer:say(message or "YOU'RE OUT!") self.announcer:say(message or "YOU'RE OUT!")
if self.outs < 3 then if self.outs < 3 then
return return
end end
@ -55,7 +65,7 @@ function baserunning:outRunner(runner, message)
end end
end end
function baserunning:outEligibleRunners(fielder) function Baserunning:outEligibleRunners(fielder)
local touchedBase = utils.isTouchingBase(fielder.x, fielder.y) local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
local didOutRunner = false local didOutRunner = false
for i, runner in pairs(self.runners) do for i, runner in pairs(self.runners) do
@ -76,7 +86,7 @@ function baserunning:outEligibleRunners(fielder)
return didOutRunner return didOutRunner
end end
function baserunning:updateForcedRunners() function Baserunning:updateForcedRunners()
local stillForced = true local stillForced = true
for _, base in ipairs(C.Bases) do for _, base in ipairs(C.Bases) do
local runnerTargetingBase = utils.getRunnerWithNextBase(self.runners, base) local runnerTargetingBase = utils.getRunnerWithNextBase(self.runners, base)
@ -93,7 +103,7 @@ function baserunning:updateForcedRunners()
end end
---@param deltaSeconds number ---@param deltaSeconds number
function baserunning:walkAwayOutRunners(deltaSeconds) function Baserunning:walkAwayOutRunners(deltaSeconds)
for i, runner in ipairs(self.outRunners) do for i, runner in ipairs(self.outRunners) do
if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
runner.x = runner.x + (deltaSeconds * 25) runner.x = runner.x + (deltaSeconds * 25)
@ -105,7 +115,7 @@ function baserunning:walkAwayOutRunners(deltaSeconds)
end end
---@return Runner ---@return Runner
function baserunning:newRunner() function Baserunning:newRunner()
local new = { local new = {
x = C.RightHandedBattersBox.x - 60, x = C.RightHandedBattersBox.x - 60,
y = C.RightHandedBattersBox.y + 60, y = C.RightHandedBattersBox.y + 60,
@ -117,11 +127,9 @@ function baserunning:newRunner()
return new return new
end end
baserunning.batter = baserunning:newRunner()
---@param self table ---@param self table
---@param runnerIndex integer ---@param runnerIndex integer
function baserunning:runnerScored(runnerIndex) function Baserunning:runnerScored(runnerIndex)
-- TODO: outRunners/scoredRunners split -- TODO: outRunners/scoredRunners split
self.outRunners[#self.outRunners + 1] = self.runners[runnerIndex] self.outRunners[#self.outRunners + 1] = self.runners[runnerIndex]
table.remove(self.runners, runnerIndex) table.remove(self.runners, runnerIndex)
@ -133,7 +141,7 @@ end
---@param appliedSpeed number ---@param appliedSpeed number
---@param deltaSeconds number ---@param deltaSeconds number
---@return boolean runnerMoved, boolean runnerScored ---@return boolean runnerMoved, boolean runnerScored
function baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSeconds) function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, deltaSeconds)
local autoRunSpeed = 20 * deltaSeconds local autoRunSpeed = 20 * deltaSeconds
if not runner or not runner.nextBase then if not runner or not runner.nextBase then
@ -188,7 +196,7 @@ end
--- 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
---@return boolean someRunnerMoved, number runnersScored ---@return boolean someRunnerMoved, number runnersScored
function baserunning:updateRunning(appliedSpeed, forcedOnly, deltaSeconds) function Baserunning:updateRunning(appliedSpeed, forcedOnly, deltaSeconds)
local someRunnerMoved = false local someRunnerMoved = false
local runnersScored = 0 local runnersScored = 0

View File

@ -1,15 +1,21 @@
-- selene: allow(unscoped_variables)
---@class Fielding
---@field fielders table<string, Fielder>
---@field fielderTouchingBall Fielder | nil
Fielding = {}
---@param name string ---@param name string
---@param speed number ---@param speed number
---@return Fielder ---@return Fielder
function newFielder(name, speed) local function newFielder(name, speed)
return { return {
name = name, name = name,
speed = speed, speed = speed,
} }
end end
-- selene: allow(unscoped_variables) function Fielding.new()
Field = { return setmetatable({
fielders = { fielders = {
first = newFielder("First", 40), first = newFielder("First", 40),
second = newFielder("Second", 40), second = newFielder("Second", 40),
@ -23,11 +29,12 @@ Field = {
}, },
---@type Fielder | nil ---@type Fielder | nil
fielderTouchingBall = nil, fielderTouchingBall = nil,
} }, { __index = Fielding })
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 Field:benchTo(position) function Fielding:benchTo(position)
self.fielders.first.target = position self.fielders.first.target = position
self.fielders.second.target = position self.fielders.second.target = position
self.fielders.shortstop.target = position self.fielders.shortstop.target = position
@ -38,7 +45,7 @@ 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).
---@param fromOffTheField XYPair | nil If provided, also sets all runners' current position to one centralized location. ---@param fromOffTheField XYPair | nil If provided, also sets all runners' current position to one centralized location.
function Field:resetFielderPositions(fromOffTheField) function Fielding:resetFielderPositions(fromOffTheField)
if fromOffTheField then if fromOffTheField then
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
fielder.x = fromOffTheField.x fielder.x = fromOffTheField.x
@ -76,7 +83,7 @@ end
---@param self table ---@param self table
---@param ballDestX number ---@param ballDestX number
---@param ballDestY number ---@param ballDestY number
function Field: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 = { x = ballDestX, y = ballDestY } chasingFielder.target = { x = ballDestX, y = ballDestY }
@ -92,7 +99,7 @@ end
---@param ball XYPair ---@param ball XYPair
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil fielderTouchingBall nil if no fielder is currently touching the ball ---@return Fielder | nil fielderTouchingBall nil if no fielder is currently touching the ball
function Field:updateFielderPositions(ball, deltaSeconds) function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderTouchingBall = nil local fielderTouchingBall = nil
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
local isTouchingBall = updateFielderPosition(deltaSeconds, fielder, ball) local isTouchingBall = updateFielderPosition(deltaSeconds, fielder, ball)
@ -120,6 +127,7 @@ local function playerThrowToImpl(field, targetBase, throwBall, throwFlyMs)
closestFielder.target = targetBase closestFielder.target = targetBase
throwBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) throwBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
return ActionResult.Succeeded
end end
--- Buffer in a fielder throw action. --- Buffer in a fielder throw action.
@ -127,7 +135,7 @@ end
---@param targetBase Base ---@param targetBase Base
---@param throwBall ThrowBall ---@param throwBall ThrowBall
---@param throwFlyMs number ---@param throwFlyMs number
function Field:playerThrowTo(targetBase, throwBall, throwFlyMs) function Fielding:playerThrowTo(targetBase, throwBall, throwFlyMs)
local maxTryTimeMs = 5000 local maxTryTimeMs = 5000
actionQueue:upsert('playerThrowTo', maxTryTimeMs, function() actionQueue:upsert('playerThrowTo', maxTryTimeMs, function()
return playerThrowToImpl(self, targetBase, throwBall, throwFlyMs) return playerThrowToImpl(self, targetBase, throwBall, throwFlyMs)

View File

@ -17,7 +17,7 @@ import 'CoreLibs/ui.lua'
--- @alias EasingFunc fun(number, number, number, number): number --- @alias EasingFunc fun(number, number, number, number): number
---@alias ThrowBall fun(destX: number, destY: number, easingFunc: EasingFunc, flyTimeMs: number | nil, floaty: boolean | nil, customBallScaler: pd_animator | nil) --- @alias ThrowBall fun(destX: number, destY: number, easingFunc: EasingFunc, flyTimeMs: number | nil, floaty: boolean | nil, customBallScaler: pd_animator | nil)
-- stylua: ignore start -- stylua: ignore start
import 'utils.lua' import 'utils.lua'
@ -38,6 +38,10 @@ import 'draw/fielder.lua'
-- selene: allow(shadowing) -- selene: allow(shadowing)
local gfx <const>, C <const> = playdate.graphics, C local gfx <const>, C <const> = playdate.graphics, C
local announcer = Announcer:new()
local baserunning = Baserunning.new(announcer)
local fielding = Fielding.new()
local PlayerImageBlipper <const> = blipper.new(100, Player, PlayerLowHat) local PlayerImageBlipper <const> = blipper.new(100, Player, PlayerLowHat)
local FielderDanceAnimator <const> = gfx.animator.new(1, 10, 0, utils.easingHill) local FielderDanceAnimator <const> = gfx.animator.new(1, 10, 0, utils.easingHill)
@ -208,7 +212,7 @@ local function outRunner(runner, message)
if not gameOver then if not gameOver then
FielderDanceAnimator:reset(C.DanceBounceMs) FielderDanceAnimator:reset(C.DanceBounceMs)
secondsSinceLastRunnerMove = -7 secondsSinceLastRunnerMove = -7
Field:benchTo(currentlyFieldingTeam.benchPosition) fielding:benchTo(currentlyFieldingTeam.benchPosition)
announcer:say("SWITCHING SIDES...") announcer:say("SWITCHING SIDES...")
end end
-- Delay to keep end-of-inning on the scoreboard for a few seconds -- Delay to keep end-of-inning on the scoreboard for a few seconds
@ -218,7 +222,7 @@ local function outRunner(runner, message)
if gameOver then if gameOver then
announcer:say("AND THAT'S THE BALL GAME!") announcer:say("AND THAT'S THE BALL GAME!")
else else
Field:resetFielderPositions() fielding:resetFielderPositions()
if battingTeam == teams.home then if battingTeam == teams.home then
inning = inning + 1 inning = inning + 1
end end
@ -259,7 +263,7 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
-- Power for this throw has already been determined -- Power for this throw has already been determined
throwMeter = 0 throwMeter = 0
Field:playerThrowTo(targetBase, throwBall, throwFlyMs) fielding:playerThrowTo(targetBase, throwBall, throwFlyMs)
secondsSinceLastRunnerMove = 0 secondsSinceLastRunnerMove = 0
offenseState = C.Offense.running offenseState = C.Offense.running
@ -330,7 +334,7 @@ local function updateBatting(batDeg, batSpeed)
baserunning.batter.forcedTo = C.Bases[C.First] baserunning.batter.forcedTo = C.Bases[C.First]
baserunning.batter = nil -- Demote batter to a mere runner baserunning.batter = nil -- Demote batter to a mere runner
Field:haveSomeoneChase(ballDestX, ballDestY) fielding:haveSomeoneChase(ballDestX, ballDestY)
end end
end end
@ -392,7 +396,7 @@ local function updateGameState()
pitchTracker.recordedPitchX = ball.x pitchTracker.recordedPitchX = ball.x
end end
local pitcher = Field.fielders.pitcher local pitcher = fielding.fielders.pitcher
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitchStartX, C.PitchStartY) < C.BaseHitbox then if utils.distanceBetween(pitcher.x, pitcher.y, C.PitchStartX, C.PitchStartY) < C.BaseHitbox then
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
end end
@ -441,7 +445,7 @@ local function updateGameState()
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
Field:resetFielderPositions() fielding:resetFielderPositions()
offenseState = C.Offense.batting offenseState = C.Offense.batting
if not baserunning.batter then if not baserunning.batter then
baserunning.batter = baserunning:newRunner() baserunning.batter = baserunning:newRunner()
@ -454,7 +458,7 @@ local function updateGameState()
end end
end end
local fielderHoldingBall = Field:updateFielderPositions(ball, deltaSeconds) local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds)
if playerOnDefense then if playerOnDefense then
local throwFly = readThrow() local throwFly = readThrow()
@ -465,7 +469,7 @@ local function updateGameState()
if fielderHoldingBall then if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
if playerOnOffense then if playerOnOffense then
npc.fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, throwBall) npc.fielderAction(fielding, baserunning, offenseState, fielderHoldingBall, outedSomeRunner, ball, throwBall)
end end
end end
@ -488,7 +492,7 @@ function playdate.update()
local fielderDanceHeight = FielderDanceAnimator:currentValue() local fielderDanceHeight = FielderDanceAnimator:currentValue()
local ballIsHeld = false local ballIsHeld = false
for _, fielder in pairs(Field.fielders) do for _, fielder in pairs(fielding.fielders) do
ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld
end end
@ -531,7 +535,7 @@ function playdate.update()
gfx.setDrawOffset(0, 0) gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(baserunning.runners, Field.fielders) drawMinimap(baserunning.runners, fielding.fielders)
end end
drawScoreboard(0, C.Screen.H * 0.77, teams, outs, battingTeam, inning) drawScoreboard(0, C.Screen.H * 0.77, teams, outs, battingTeam, inning)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes) drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
@ -542,7 +546,7 @@ local function init()
playdate.display.setRefreshRate(50) playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite) gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
Field:resetFielderPositions(teams.home.benchPosition) fielding:resetFielderPositions(teams.home.benchPosition)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function() playdate.timer.new(2000, function()

View File

@ -85,13 +85,14 @@ function npc.getNextOutTarget(runners)
end end
end end
---@param fielding Fielding
---@param fielder Fielder ---@param fielder Fielder
---@param runners Runner[] ---@param runners Runner[]
---@param throwBall ThrowBall ---@param throwBall ThrowBall
function npc.tryToMakeAPlay(fielder, runners, ball, throwBall) function npc.tryToMakeAPlay(fielding, fielder, runners, ball, throwBall)
local targetX, targetY = npc.getNextOutTarget(runners) local targetX, targetY = npc.getNextOutTarget(runners)
if targetX ~= nil and targetY ~= nil then if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(Field.fielders, targetX, targetY) local nearestFielder = utils.getNearestOf(fielding.fielders, targetX, targetY)
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
@ -101,22 +102,24 @@ function npc.tryToMakeAPlay(fielder, runners, ball, throwBall)
end end
end end
---@param fielding Fielding
---@param baserunning Baserunning
---@param offenseState OffenseState ---@param offenseState OffenseState
---@param fielder Fielder ---@param fielder Fielder
---@param outedSomeRunner boolean ---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil } ---@param ball { x: number, y: number, heldBy: Fielder | nil }
---@param throwBall ThrowBall ---@param throwBall ThrowBall
function npc.fielderAction(offenseState, fielder, outedSomeRunner, ball, throwBall) function npc.fielderAction(fielding, baserunning, offenseState, fielder, outedSomeRunner, ball, throwBall)
if offenseState ~= C.Offense.running then if offenseState ~= C.Offense.running then
return return
end end
if outedSomeRunner then if outedSomeRunner then
-- Delay a little before the next play -- Delay a little before the next play
playdate.timer.new(750, function() playdate.timer.new(750, function()
npc.tryToMakeAPlay(fielder, baserunning.runners, ball, throwBall) npc.tryToMakeAPlay(fielding, fielder, baserunning.runners, ball, throwBall)
end) end)
else else
npc.tryToMakeAPlay(fielder, baserunning.runners, ball, throwBall) npc.tryToMakeAPlay(fielding, fielder, baserunning.runners, ball, throwBall)
end end
end end

View File

@ -138,7 +138,7 @@ function utils.getRunnerWithNextBase(runners, base)
end end
--- Returns true only if the point is below the given line, within the x bounds of said line, and above the bottomBound --- Returns true only if the point is below the given line, within the x bounds of said line, and above the bottomBound
--- @return boolean ---@return boolean
function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2, bottomBound) function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2, bottomBound)
-- This check currently assumes right-handedness. -- This check currently assumes right-handedness.
-- I.e. it assumes the ball is to the right of batBaseX -- I.e. it assumes the ball is to the right of batBaseX