Refactoring and better type hinting.

This commit is contained in:
Sage Vaillancourt 2025-02-05 10:01:45 -05:00
parent 841b7e3fea
commit 45a20cbb70
2 changed files with 206 additions and 127 deletions

View File

@ -26,12 +26,14 @@ import 'CoreLibs/ui.lua'
--- } --- }
--- @alias Fielder { --- @alias Fielder {
--- x: number | nil, --- x: number,
--- y: number | nil, --- y: number,
--- target: XYPair | nil, --- target: XYPair | nil,
--- speed: number, --- speed: number,
--- } --- }
--- @alias EasingFunc fun(number, number, number, number): number
import 'announcer.lua' import 'announcer.lua'
import 'graphics.lua' import 'graphics.lua'
import 'scoreboard.lua' import 'scoreboard.lua'
@ -77,6 +79,10 @@ local PitchStartY <const>, PitchEndY <const> = 105, 240
local ballAnimatorY = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear) local ballAnimatorY = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear)
local ballAnimatorX = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear) local ballAnimatorX = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear)
---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self) }
---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil }
---@type Pitch[]
local Pitches <const> = { local Pitches <const> = {
-- Fastball -- Fastball
{ {
@ -154,13 +160,17 @@ local Bases = {
-- Pseudo-base for batter to target -- Pseudo-base for batter to target
local RightHandedBattersBox <const> = xy(Bases[Home].x - 35, Bases[Home].y) local RightHandedBattersBox <const> = xy(Bases[Home].x - 35, Bases[Home].y)
---@type table<Base, Base> ---@type table<Base, Base | nil>
local NextBaseMap <const> = { local NextBaseMap <const> = {
[RightHandedBattersBox] = nil, -- Runner should not escape the box before a hit!
[Bases[First]] = Bases[Second], [Bases[First]] = Bases[Second],
[Bases[Second]] = Bases[Third], [Bases[Second]] = Bases[Third],
[Bases[Third]] = Bases[Home], [Bases[Third]] = Bases[Home],
} }
---@param name string
---@param speed number
---@return Fielder
function newFielder(name, speed) function newFielder(name, speed)
return { return {
name = name, name = name,
@ -233,6 +243,12 @@ end
local batter = newRunner() local batter = newRunner()
--- "Throws" the ball from its current position to the given destination. --- "Throws" 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
function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
if not flyTimeMs then if not flyTimeMs then
flyTimeMs = distanceBetween(ball.x, ball.y, destX, destY) * 5 flyTimeMs = distanceBetween(ball.x, ball.y, destX, destY) * 5
@ -282,7 +298,11 @@ local crankChange = 0
local acceleratedChange local acceleratedChange
local BaseHitbox = 10 local BaseHitbox = 10
--- Returns the base being touched by the runner at (x,y), or nil, if no base is being touched
--- Returns the base being touched by the player at (x,y), or nil, if no base is being touched
---@param x number
---@param y number
---@return Base | nil
function isTouchingBase(x, y) function isTouchingBase(x, y)
for _, base in ipairs(Bases) do for _, base in ipairs(Bases) do
if distanceBetween(x, y, base.x, base.y) < BaseHitbox then if distanceBetween(x, y, base.x, base.y) < BaseHitbox then
@ -294,11 +314,19 @@ function isTouchingBase(x, y)
end end
local BallCatchHitbox = 3 local BallCatchHitbox = 3
--- Returns true only if the given point is touching the ball at its current position
---@param x number
---@param y number
---@return boolean
function isTouchingBall(x, y) function isTouchingBall(x, y)
local ballDistance = distanceBetween(x, y, ball.x, ball.y) local ballDistance = distanceBetween(x, y, ball.x, ball.y)
return ballDistance < BallCatchHitbox return ballDistance < BallCatchHitbox
end end
---@alias Team { score: number }
---@type table<string, Team>
local teams <const> = { local teams <const> = {
home = { home = {
score = 0, score = 0,
@ -372,7 +400,7 @@ function outRunner(runnerIndex)
end end
end end
-- TODO: Away score ---@param runnerIndex number
function score(runnerIndex) function score(runnerIndex)
outRunners[#outRunners + 1] = runners[runnerIndex] outRunners[#outRunners + 1] = runners[runnerIndex]
table.remove(runners, runnerIndex) table.remove(runners, runnerIndex)
@ -427,52 +455,66 @@ function getNextOutTarget()
end end
end end
function tryToMakeAnOut(thrower) ---@param fielder Fielder
function tryToMakeAnOut(fielder)
local targetX, targetY = getNextOutTarget() local targetX, targetY = getNextOutTarget()
if targetX ~= nil and targetY ~= nil then if targetX ~= nil and targetY ~= nil then
local nearestFielder = getNearestOf(fielders, targetX, targetY) local nearestFielder = getNearestOf(fielders, targetX, targetY)
nearestFielder.target = xy(targetX, targetY) nearestFielder.target = xy(targetX, targetY)
if nearestFielder == thrower then if nearestFielder == fielder then
ball.heldBy = thrower ball.heldBy = fielder
else else
throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true) throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
end end
end end
end end
function updateFielders() --- Push the given obect at the given speed toward a target. Speed should be pre-multiplied by the frame's delta time.
local touchingBaseCache = buildCache(function(runner) --- Stops when within 1. Returns true only if the object did actually move.
return isTouchingBase(runner.x, runner.y) ---@param mover { x: number, y: number }
end) ---@param speed number
---@param target { x: number, y: number }
---@return boolean
function moveAtSpeed(mover, speed, target)
local x, y, distance = normalizeVector(mover.x, mover.y, target.x, target.y)
for _, fielder in pairs(fielders) do if distance > 1 then
mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed)
return true
end
return false
end
---@param fielder Fielder
---@param runnerBaseCache Cache<Runner, Base>
function updateFielder(fielder, runnerBaseCache)
-- TODO: Target unforced runners (or their target bases) for tagging -- TODO: Target unforced runners (or their target bases) for tagging
-- With new Position-based scheme, fielders are now able to set `fielder.target = runner` to track directly -- With new Position-based scheme, fielders are now able to set `fielder.target = runner` to track directly
if fielder.target ~= nil then if fielder.target ~= nil then
local x, y, distance = normalizeVector(fielder.x, fielder.y, fielder.target.x, fielder.target.y) if not moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
if distance > 1 then
fielder.x = fielder.x - (x * fielder.speed * deltaSeconds)
fielder.y = fielder.y - (y * fielder.speed * deltaSeconds)
else
fielder.target = nil fielder.target = nil
end end
end end
if currentMode == Modes.running and isTouchingBall(fielder.x, fielder.y) then if currentMode ~= Modes.running or not isTouchingBall(fielder.x, fielder.y) then
return
end
-- TODO: Check for double-plays or other available outs. -- TODO: Check for double-plays or other available outs.
local touchedBase = isTouchingBase(fielder.x, fielder.y) local touchedBase = isTouchingBase(fielder.x, fielder.y)
for i, runner in pairs(runners) do for i, runner in pairs(runners) do
if if
( -- Force out ( -- Force out
touchedBase touchedBase
and runner.prevBase -- Make sure the runner is not standing at home -- and runner.prevBase -- Make sure the runner is not standing at home
and runner.forcedTo == touchedBase and runner.forcedTo == touchedBase
and touchedBase ~= touchingBaseCache.get(runner) and touchedBase ~= runnerBaseCache.get(runner)
) )
or ( -- Tag out or ( -- Tag out
not touchingBaseCache.get(runner) not runnerBaseCache.get(runner)
and distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < TagDistance and distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < TagDistance
) )
then then
@ -484,36 +526,60 @@ function updateFielders()
tryToMakeAnOut(fielder) tryToMakeAnOut(fielder)
end end
end end
end
end
end end
--- Returns true if at least one runner is still moving function updateFielders()
local runnerBaseCache = buildCache(function(runner)
return isTouchingBase(runner.x, runner.y)
end)
for _, fielder in pairs(fielders) do
updateFielder(fielder, runnerBaseCache)
end
-- if currentMode == Modes.batting then
-- moveAtSpeed(
-- fielders.catcher,
-- fielders.catcher.speed * 2 * deltaSeconds,
-- { x = math.min(Center.x + 15, ball.x), y = fielders.catcher.y }
-- )
-- end
end
--- Returns true only if the given runner moved during this update.
---@param runner Runner | nil
---@param runnerIndex integer | nil May only be nil if runner == batter
---@return boolean ---@return boolean
function updateRunners(currentRunners) function updateRunner(runner, runnerIndex)
local autoRunSpeed = 20 * deltaSeconds local autoRunSpeed = 20 * deltaSeconds
--autoRunSpeed = 140 --autoRunSpeed = 140
-- TODO: Filter for the runner closest to the currently-held direction button
local runnerMoved = false if not runner or not runner.nextBase then
for runnerIndex, runner in ipairs(currentRunners) do return false
end
local appliedSpeed = crankChange -- TODO: Allow for individual runner control via buttons local appliedSpeed = crankChange -- TODO: Allow for individual runner control via buttons
local nearestBase, nearestBaseDistance = getNearestOf(Bases, runner.x, runner.y) local nearestBase, nearestBaseDistance = getNearestOf(Bases, runner.x, runner.y)
if if
nearestBaseDistance < 5 nearestBaseDistance < 5
and runner.prevBase and runnerIndex ~= nil
and runner ~= batter --runner.prevBase
and runner.nextBase == Bases[Home] and runner.nextBase == Bases[Home]
and nearestBase == Bases[Home] and nearestBase == Bases[Home]
then then
score(runnerIndex) score(runnerIndex)
end end
if runner.nextBase then
local nb = runner.nextBase local nb = runner.nextBase
local x, y, distance = normalizeVector(runner.x, runner.y, nb.x, nb.y) local x, y, distance = normalizeVector(runner.x, runner.y, nb.x, nb.y)
if distance > 1 then if distance < 1 then
runner.nextBase = NextBaseMap[runner.nextBase]
runner.forcedTo = nil
return false
end
local prevX, prevY = runner.x, runner.y local prevX, prevY = runner.x, runner.y
local mult = 1 local mult = 1
if appliedSpeed < 0 then if appliedSpeed < 0 then
@ -533,37 +599,14 @@ function updateRunners(currentRunners)
runner.x = runner.x - (x * mult) runner.x = runner.x - (x * mult)
runner.y = runner.y - (y * mult) runner.y = runner.y - (y * mult)
runnerMoved = runnerMoved or prevX ~= runner.x or prevY ~= runner.y return prevX ~= runner.x or prevY ~= runner.y
else
runner.nextBase = NextBaseMap[runner.nextBase]
runner.forcedTo = nil
end
end
end
return runnerMoved
end end
local ResetFieldersAfterSeconds = 2 local ResetFieldersAfterSeconds <const> = 5
-- TODO: Replace with a timer, repeatedly reset instead of setting to 0 -- TODO: Replace with a timer, repeatedly reset, instead of setting to 0
local secondsSinceLastRunnerMove = 0 local secondsSinceLastRunnerMove = 0
function init() ---@type number
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
resetFielderPositions(true)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function()
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
end
local batAngleDeg local batAngleDeg
function updateBatting() function updateBatting()
@ -581,8 +624,9 @@ function updateBatting()
batTip.y = batBase.y + (BatLength * math.cos(batAngle)) batTip.y = batBase.y + (BatLength * math.cos(batAngle))
if if
acceleratedChange >= 0 acceleratedChange > 0
and pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H) and pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H)
and ball.y < 232 --not isTouchingBall(fielders.catcher.x, fielders.catcher.y)
then then
BatCrackSound:play() BatCrackSound:play()
currentMode = Modes.running currentMode = Modes.running
@ -619,25 +663,27 @@ function updateBatting()
end end
end end
--- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update
---@return boolean
function updateRunning() function updateRunning()
ball.size = ballSizeAnimator:currentValue()
local nonBatterRunners = filter(runners, function(runner) local nonBatterRunners = filter(runners, function(runner)
return runner ~= batter return runner ~= batter
end) end)
ball.size = ballSizeAnimator:currentValue()
if updateRunners(nonBatterRunners) then local runnerMoved = false
secondsSinceLastRunnerMove = 0
else -- TODO: Filter for the runner closest to the currently-held direction button
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds for runnerIndex, runner in ipairs(nonBatterRunners) do
if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then runnerMoved = updateRunner(runner, runnerIndex) or runnerMoved
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
resetFielderPositions(false)
currentMode = Modes.batting
batter = newRunner()
end
end end
return runnerMoved
end end
function updateOutRunners() function walkAwayOutRunners()
for i, runner in ipairs(outRunners) do for i, runner in ipairs(outRunners) do
if runner.x < Screen.W + 50 and runner.y < Screen.H + 50 then if runner.x < Screen.W + 50 and runner.y < Screen.H + 50 then
runner.x = runner.x + (deltaSeconds * 25) runner.x = runner.x + (deltaSeconds * 25)
@ -672,13 +718,24 @@ function updateGameState()
pitch() pitch()
end end
updateBatting() updateBatting()
updateRunners({ batter }) -- TODO: Ensure batter can't be nil, here
updateRunner(batter)
elseif currentMode == Modes.running then elseif currentMode == Modes.running then
updateRunning() if updateRunning() then
secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
resetFielderPositions(false)
currentMode = Modes.batting
batter = newRunner()
end
end
end end
updateFielders() updateFielders()
updateOutRunners() walkAwayOutRunners()
end end
-- TODO -- TODO
@ -741,11 +798,27 @@ function playdate.update()
gfx.drawCircleAtPoint(ball.x, ball.y, ball.size) gfx.drawCircleAtPoint(ball.x, ball.y, ball.size)
gfx.setDrawOffset(0, 0) gfx.setDrawOffset(0, 0)
if offsetX > 0 or offsetY > 0 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap() drawMinimap()
end end
drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning) drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning)
announcer:draw(Center.x, 10) announcer:draw(Center.x, 10)
end end
function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
resetFielderPositions(true)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function()
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
end
init() init()

View File

@ -34,7 +34,11 @@ function xy(x, y)
end end
--- Returns the normalized vector as two values, plus the distance between the given points. --- Returns the normalized vector as two values, plus the distance between the given points.
---@return number, number, number ---@param x1 number
---@param y1 number
---@param x2 number
---@param y2 number
---@return number x, number y, number distance
function normalizeVector(x1, y1, x2, y2) function normalizeVector(x1, y1, x2, y2)
local distance, a, b = distanceBetween(x1, y1, x2, y2) local distance, a, b = distanceBetween(x1, y1, x2, y2)
return a / distance, b / distance, distance return a / distance, b / distance, distance
@ -108,6 +112,8 @@ function getNearestOf(array, x, y, extraCondition)
return nearest, nearestDistance return nearest, nearestDistance
end end
---@alias Cache { get: fun(key: `Key`): `Value` }
--- Marker used by buildCache to indicate a cached `nil` value. --- Marker used by buildCache to indicate a cached `nil` value.
local NoValue <const> = {} local NoValue <const> = {}
@ -120,7 +126,7 @@ local NoValue <const> = {}
--- ---
---@generic Key ---@generic Key
---@generic Value ---@generic Value
---@return { get: fun(key: Key): Value } ---@return Cache<Key, Value>
function buildCache(fetcher) function buildCache(fetcher)
local cacheData = {} local cacheData = {}
return { return {