Implementing walks and strike-outs

* Look at limiting batting/throw power with math.log()
* Extract draw/fielder.lua
* Extract some values into constants.lua
* Extract npc.lua for computer batting (and eventually probably more CPU behavior)
* pitchTracker in utils
This commit is contained in:
Sage Vaillancourt 2025-02-07 20:29:40 -05:00
parent 969de111fe
commit 881ff0e734
8 changed files with 226 additions and 145 deletions

View File

@ -1,4 +1,4 @@
SOURCE_FILES := src/utils.lua src/dbg.lua src/announcer.lua src/graphics.lua src/scoreboard.lua src/main.lua
SOURCE_FILES := src/constants.lua src/draw/* src/utils.lua src/dbg.lua src/npc.lua src/announcer.lua src/graphics.lua src/scoreboard.lua src/main.lua
all:
pdc src BatterUp.pdx

View File

@ -1,3 +1,6 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics
local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft")
local AnnouncementTransitionMs <const> = 300
local AnnouncerMarginX <const> = 26
@ -49,7 +52,6 @@ function announcer.draw(self, x, y)
end
x = x - 5 -- Infield center is slightly offset from screen center
local gfx = playdate.graphics
local originalDrawMode = gfx.getImageDrawMode()
local width = math.max(150, (AnnouncerMarginX * 2) + AnnouncementFont:getTextWidth(self.textQueue[1]))
local animY = self.animatorY:currentValue()

13
src/constants.lua Normal file
View File

@ -0,0 +1,13 @@
-- selene: allow(unscoped_variables)
C = {}
C.Screen = {
W = playdate.display.getWidth(),
H = playdate.display.getHeight(),
}
C.Center = utils.xy(C.Screen.W / 2, C.Screen.H / 2)
C.StrikeZoneStartX = C.Center.x - 16
C.StrikeZoneEndX = C.StrikeZoneStartX + 24
C.StrikeZoneStartY = C.Screen.H - 35

29
src/draw/fielder.lua Normal file
View File

@ -0,0 +1,29 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics
local Glove <const> = playdate.graphics.image.new("images/game/glove.png") --[[@as pd_image]]
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
local GloveHoldingBall <const> = playdate.graphics.image.new("images/game/glove-holding-ball.png") --[[@as pd_image]]
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
---@param fielderX number
---@param fielderY number
---@return boolean isHoldingBall
local function drawFielderGlove(ball, fielderX, fielderY)
local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
local shoulderX, shoulderY = fielderX + 10, fielderY + 5
if distanceFromBall > 20 then
Glove:draw(shoulderX, shoulderY)
return false
else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY)
return true
end
end
---@return boolean isHoldingBall
function drawFielder(ball, x, y)
gfx.fillRect(x, y, 14, 25)
return drawFielderGlove(ball, x, y)
end

View File

@ -34,22 +34,20 @@ import 'CoreLibs/ui.lua'
--- @alias EasingFunc fun(number, number, number, number): number
import 'utils.lua'
import 'constants.lua'
import 'announcer.lua'
import 'dbg.lua'
import 'graphics.lua'
import 'npc.lua'
import 'scoreboard.lua'
import 'utils.lua'
import 'draw/fielder'
-- stylua: ignore end
-- selene: allow(shadowing)
local gfx <const> = playdate.graphics
local Screen <const> = {
W = playdate.display.getWidth(),
H = playdate.display.getHeight(),
}
local Center <const> = utils.xy(Screen.W / 2, Screen.H / 2)
local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune.wav")
-- local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune-organy.wav")
local TinnyBackground <const> = playdate.sound.sampleplayer.new("sounds/tinny-background.wav")
@ -60,11 +58,6 @@ local PlayerFrown <const> = gfx.image.new("images/game/player-frown.png") --[[@a
local PlayerSmile <const> = gfx.image.new("images/game/player.png") --[[@as pd_image]]
local PlayerBack <const> = gfx.image.new("images/game/player-back.png") --[[@as pd_image]]
local Glove <const> = gfx.image.new("images/game/glove.png") --[[@as pd_image]]
local GloveHoldingBall <const> = gfx.image.new("images/game/glove-holding-ball.png") --[[@as pd_image]]
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
local PlayerImageBlipper <const> = blipper.new(100, "images/game/player.png", "images/game/player-lowhat.png")
local DanceBounceMs <const> = 500
@ -122,7 +115,7 @@ local Pitches <const> = {
local CrankOffsetDeg <const> = 90
local BatOffset <const> = utils.xy(10, 25)
local batBase <const> = utils.xy(Center.x - 34, 215)
local batBase <const> = utils.xy(C.Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)
local TagDistance <const> = 15
@ -130,8 +123,9 @@ local TagDistance <const> = 15
local SmallestBallRadius <const> = 6
local ball <const> = {
x = Center.x --[[@as number]],
y = Center.y --[[@as number]],
x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]],
z = 0,
size = SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]],
}
@ -141,6 +135,7 @@ local BatLength <const> = 50
local Offense <const> = {
batting = {},
running = {},
walking = {},
}
local Sides <const> = {
@ -156,11 +151,11 @@ local offenseMode = Offense.batting
local teams <const> = {
home = {
score = 0,
benchPosition = utils.xy(Screen.W + 10, Center.y),
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
},
away = {
score = 0,
benchPosition = utils.xy(-10, Center.y),
benchPosition = utils.xy(-10, C.Center.y),
},
}
@ -194,10 +189,10 @@ local First <const>, Second <const>, Third <const>, Home <const> = 1, 2, 3, 4
---@type Base[]
local Bases = {
utils.xy(Screen.W * 0.93, Screen.H * 0.52),
utils.xy(Screen.W * 0.47, Screen.H * 0.19),
utils.xy(Screen.W * 0.03, Screen.H * 0.52),
utils.xy(Screen.W * 0.474, Screen.H * 0.79),
utils.xy(C.Screen.W * 0.93, C.Screen.H * 0.52),
utils.xy(C.Screen.W * 0.47, C.Screen.H * 0.19),
utils.xy(C.Screen.W * 0.03, C.Screen.H * 0.52),
utils.xy(C.Screen.W * 0.474, C.Screen.H * 0.79),
}
-- Pseudo-base for batter to target
@ -230,13 +225,13 @@ local fielders <const> = {
pitcher = newFielder("Pitcher", 30),
catcher = newFielder("Catcher", 35),
left = newFielder("Left", 40),
center = newFielder("Center", 40),
center = newFielder("C.Center", 40),
right = newFielder("Right", 40),
}
local PitcherStartPos <const> = {
x = Screen.W * 0.48,
y = Screen.H * 0.40,
x = C.Screen.W * 0.48,
y = C.Screen.H * 0.40,
}
--- Actually only benches the infield, because outfielders are far away!
@ -260,15 +255,15 @@ function resetFielderPositions(fromOffTheField)
end
end
fielders.first.target = utils.xy(Screen.W - 65, Screen.H * 0.48)
fielders.second.target = utils.xy(Screen.W * 0.70, Screen.H * 0.30)
fielders.shortstop.target = utils.xy(Screen.W * 0.30, Screen.H * 0.30)
fielders.third.target = utils.xy(Screen.W * 0.1, Screen.H * 0.48)
fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48)
fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30)
fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30)
fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48)
fielders.pitcher.target = utils.xy(PitcherStartPos.x, PitcherStartPos.y)
fielders.catcher.target = utils.xy(Screen.W * 0.475, Screen.H * 0.92)
fielders.left.target = utils.xy(Screen.W * -1, Screen.H * -0.2)
fielders.center.target = utils.xy(Center.x, Screen.H * -0.4)
fielders.right.target = utils.xy(Screen.W * 2, fielders.left.target.y)
fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92)
fielders.left.target = utils.xy(C.Screen.W * -1, C.Screen.H * -0.2)
fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4)
fielders.right.target = utils.xy(C.Screen.W * 2, fielders.left.target.y)
end
local BatterStartingX <const> = Bases[Home].x - 40
@ -409,15 +404,22 @@ local ResetFieldersAfterSeconds <const> = 5
-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0
local secondsSinceLastRunnerMove = 0
---@param runnerIndex integer
function outRunner(runnerIndex)
outRunners[#outRunners + 1] = runners[runnerIndex]
table.remove(runners, runnerIndex)
---@param runner integer | Runner
function outRunner(runner, message)
if type(runner) ~= "number" then
for i, maybe in ipairs(runners) do
if runner == maybe then
runner = i
end
end
end
outRunners[#outRunners + 1] = runners[runner]
table.remove(runners, runner)
outs = outs + 1
updateForcedRunners()
announcer:say("YOU'RE OUT!")
announcer:say(message or "YOU'RE OUT!")
if outs == 3 then
local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home
local gameOver = inning == 9 and teams.away.score ~= teams.home.score
@ -437,6 +439,7 @@ function outRunner(runnerIndex)
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
else
resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
end
@ -665,53 +668,34 @@ end
---@type number
local batAngleDeg
-- Used for tracking whether or not a pitch was a strike
local recordedPitchX = nil
local balls = 0
local strikes = 0
local StrikeZoneStartX <const> = Center.x - 16
local StrikeZoneEndX <const> = StrikeZoneStartX + 24
local StrikeZoneStartY <const> = Screen.H - 35
function recordStrikePosition()
if not recordedPitchX and ball.y > StrikeZoneStartY then
recordedPitchX = ball.x
end
end
function nextBatter()
batter = nil
playdate.timer.new(2000, function()
balls = 0
strikes = 0
batter = newRunner()
pitchTracker:reset()
if not batter then
batter = newRunner()
end
end)
end
function walk()
-- TODO
announcer:say("Walk!")
fielders.first.target = Bases[First]
batter.nextBase = Bases[First]
batter.prevBase = Bases[Home]
offenseMode = Offense.walking
batter = nil
updateForcedRunners()
nextBatter()
end
function strikeOut()
-- TODO
local outBatter = batter
batter = nil
outRunner(outBatter --[[@as Runner]], "Strike out!")
nextBatter()
end
function recordPitch()
if recordedPitchX > StrikeZoneStartX and recordedPitchX < StrikeZoneEndX then
strikes = strikes + 1
if strikes >= 3 then
strikeOut()
end
else
balls = balls + 1
if balls >= 4 then
walk()
end
end
end
---@param batDeg number
function updateBatting(batDeg, batSpeed)
if ball.y < BallOffscreen then
@ -728,7 +712,7 @@ function updateBatting(batDeg, batSpeed)
if
batSpeed > 0
and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H)
and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H)
and ball.y < 232 --not isTouchingBall(fielders.catcher.x, fielders.catcher.y)
then
BatCrackSound:play()
@ -770,14 +754,14 @@ end
--- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number
---@return boolean
function updateRunning(appliedSpeed)
function updateRunning(appliedSpeed, forcedOnly)
ball.size = ballSizeAnimator:currentValue()
local runnerMoved = false
-- TODO: Filter for the runner closest to the currently-held direction button
for runnerIndex, runner in ipairs(runners) do
if runner ~= batter then
if runner ~= batter and (not forcedOnly or runner.forcedTo) then
runnerMoved = updateRunner(runner, runnerIndex, appliedSpeed) or runnerMoved
end
end
@ -787,7 +771,7 @@ end
function walkAwayOutRunners()
for i, runner in ipairs(outRunners) do
if runner.x < Screen.W + 50 and runner.y < 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.y = runner.y + (deltaSeconds * 25)
else
@ -810,76 +794,66 @@ function playerPitch(throwFly)
end
end
local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1500
local npcBatSpeed = 1500
function npcBatAngle()
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < Center.x + 15) then
npcBatDeg = npcBatDeg + (deltaSeconds * npcBatSpeed)
else
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
npcBatDeg = 200
end
return npcBatDeg
end
function npcBatChange()
return deltaSeconds * npcBatSpeed
end
function npcRunningSpeed()
if #runners == 0 then
return 0
end
local touchedBase = isTouchingBase(runners[1].x, runners[1].y)
if not touchedBase or touchedBase == Bases[Home] then
return 10
end
return 0
end
function updateGameState()
deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime()
local crankChange = playdate.getCrankChange() --[[@as number, number]]
local crankChange = playdate.getCrankChange() --[[@as number]]
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * 10)
if crankChange < 0 then
crankLimited = crankLimited * -1
end
if ball.heldBy then
ball.x = ball.heldBy.x
ball.y = ball.heldBy.y
else
ball.x = ballAnimatorX:currentValue()
ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue()
ball.z = ballFloatAnimator:currentValue()
ball.y = ballAnimatorY:currentValue() + ball.z
end
local playerOnOffense, playerOnDefense = playerIsOn(Sides.offense)
if playerOnDefense then
throwMeter = math.max(0, throwMeter - (deltaSeconds * 150))
throwMeter = throwMeter + math.abs(crankChange)
throwMeter = throwMeter + math.abs(crankLimited)
end
if offenseMode == Offense.batting then
recordStrikePosition()
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
if ball.y < C.StrikeZoneStartY then
pitchTracker.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then
pitchTracker.recordedPitchX = ball.x
end
if utils.distanceBetween(fielders.pitcher.x, fielders.pitcher.y, PitchStartX, PitchStartY) < BaseHitbox then
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
end
if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then
recordPitch()
local outcome = pitchTracker:updatePitchCounts()
if outcome == PitchOutcomes.StrikeOut then
strikeOut()
elseif outcome == PitchOutcomes.Walk then
walk()
end
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
catcherThrownBall = true
end
local batSpeed
if playerOnOffense then
batAngleDeg, batSpeed = (playdate.getCrankPosition() + CrankOffsetDeg) % 360, crankChange
batAngleDeg = (playdate.getCrankPosition() + CrankOffsetDeg) % 360
batSpeed = crankLimited
else
batAngleDeg, batSpeed = npcBatAngle(), npcBatChange()
batAngleDeg = npc.updateBatAngle(ball, catcherThrownBall, deltaSeconds)
batSpeed = npc.batSpeed() * deltaSeconds
end
updateBatting(batAngleDeg, batSpeed)
-- TODO: Ensure batter can't be nil, here
updateRunner(batter, nil, crankChange)
updateRunner(batter, nil, crankLimited)
if secondsSincePitchAllowed > PitchAfterSeconds then
if playerOnDefense then
@ -892,7 +866,7 @@ function updateGameState()
end
end
elseif offenseMode == Offense.running then
local appliedSpeed = playerOnOffense and crankChange or npcRunningSpeed()
local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(Bases, runners)
if updateRunning(appliedSpeed) then
secondsSinceLastRunnerMove = 0
else
@ -906,6 +880,11 @@ function updateGameState()
end
end
end
elseif offenseMode == Offense.walking then
updateForcedRunners()
if not updateRunning(10, true) then
offenseMode = Offense.batting
end
end
for _, fielder in pairs(fielders) do
@ -915,11 +894,11 @@ function updateGameState()
end
local MinimapSizeX, MinimapSizeY <const> = Minimap:getSize()
local MinimapPosX, MinimapPosY = Screen.W - MinimapSizeX, Screen.H - MinimapSizeY
local MinimapPosX, MinimapPosY = C.Screen.W - MinimapSizeX, C.Screen.H - MinimapSizeY
local FieldHeight <const> = Bases[Home].y - Bases[Second].y
local MinimapMultX <const> = 0.75 * MinimapSizeX / Screen.W
local MinimapMultX <const> = 0.75 * MinimapSizeX / C.Screen.W
local MinimapOffsetX <const> = MinimapPosX + 5
local MinimapMultY <const> = 0.70 * MinimapSizeY / FieldHeight
local MinimapOffsetY <const> = MinimapPosY - 15
@ -934,21 +913,6 @@ function drawMinimap()
end
end
---@param fielder Fielder
---@return boolean isHoldingBall
function drawFielderGlove(fielder)
local distanceFromBall =
utils.distanceBetweenZ(fielder.x, fielder.y, 0, ball.x, ball.y, ballFloatAnimator:currentValue())
local shoulderX, shoulderY = fielder.x + 10, fielder.y + FielderDanceAnimator:currentValue() + 5
if distanceFromBall > 20 then
Glove:draw(shoulderX, shoulderY)
return false
else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY)
return true
end
end
function playdate.update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
@ -959,7 +923,7 @@ function playdate.update()
local offsetX, offsetY = 0, 0
if ball.x < BallOffscreen then
offsetX, offsetY = getDrawOffset(Screen.W, Screen.H, ball.x, ball.y)
offsetX, offsetY = getDrawOffset(C.Screen.W, C.Screen.H, ball.x, ball.y)
gfx.setDrawOffset(offsetX, offsetY)
end
@ -968,9 +932,7 @@ function playdate.update()
local fielderDanceHeight = FielderDanceAnimator:currentValue()
local ballIsHeld = false
for _, fielder in pairs(fielders) do
local fielderY = fielder.y + fielderDanceHeight
gfx.fillRect(fielder.x, fielderY, 14, 25)
ballIsHeld = drawFielderGlove(fielder) or ballIsHeld
ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld
end
if offenseMode == Offense.batting then
@ -1013,9 +975,9 @@ function playdate.update()
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap()
end
drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning)
drawBallsAndStrikes(290, Screen.H - 20, balls, strikes)
announcer:draw(Center.x, 10)
drawScoreboard(0, C.Screen.H * 0.77, teams, outs, battingTeam, inning)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
announcer:draw(C.Center.x, 10)
end
function init()

35
src/npc.lua Normal file
View File

@ -0,0 +1,35 @@
local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1500
local npcBatSpeed = 1500
-- selene: allow(unscoped_variables)
npc = {}
function npc.updateBatAngle(ball, catcherThrownBall, deltaSec)
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
else
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
npcBatDeg = 200
end
return npcBatDeg
end
function npc.batSpeed()
return npcBatSpeed
end
function npc.runningSpeed(baseConstants, runners)
if #runners == 0 then
return 0
end
local touchedBase = isTouchingBase(runners[1].x, runners[1].y)
if not touchedBase or touchedBase == baseConstants[4] then
return 10
end
return 0
end
if not playdate then
return utils
end

View File

@ -1,3 +1,6 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics
local ScoreFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft")
local OutBubbleRadius <const> = 5
local ScoreboardMarginX <const> = 6
@ -25,7 +28,6 @@ function drawBallsAndStrikes(x, y, balls, strikes)
return
end
local gfx = playdate.graphics
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(x, y, BallStrikeWidth, BallStrikeHeight)
local originalDrawMode = gfx.getImageDrawMode()
@ -39,7 +41,6 @@ function drawBallsAndStrikes(x, y, balls, strikes)
end
function drawScoreboard(x, y, teams, outs, battingTeam, inning)
local gfx = playdate.graphics
local homeScore = teams.home.score
local awayScore = teams.away.score

View File

@ -142,8 +142,47 @@ function utils.getNearestOf(array, x, y, extraCondition)
return nearest, nearestDistance
end
---@alias Cache { get: fun(key: `Key`): `Value` }
-- selene: allow(unscoped_variables)
PitchOutcomes = {
StrikeOut = {},
Walk = {},
}
-- selene: allow(unscoped_variables)
pitchTracker = {
--- Position of the pitch, or nil, if one has not been recorded.
---@type number | nil
recordedPitchX = nil,
strikes = 0,
balls = 0,
reset = function(self)
self.strikes = 0
self.balls = 0
end,
updatePitchCounts = function(self)
if not self.recordedPitchX then
return
end
if self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
self.strikes = self.strikes + 1
if self.strikes >= 3 then
self:reset()
return PitchOutcomes.StrikeOut
end
else
self.balls = self.balls + 1
if self.balls >= 4 then
self:reset()
return PitchOutcomes.Walk
end
end
end,
}
if not playdate then
return utils
return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes }
end