Compare commits

..

1 Commits

Author SHA1 Message Date
Sage Vaillancourt 58c6ab9fe6 Toying with trajectory-based missed-ball physics 2025-02-25 08:13:40 -05:00
17 changed files with 312 additions and 523 deletions

View File

@ -2,10 +2,11 @@
---@field x number ---@field x number
---@field y number ---@field y number
---@field z number ---@field z number
---@field trajectory Point3d
---@field size number ---@field size number
---@field heldBy Fielder | nil ---@field heldBy Fielder | nil
---@field catchable boolean
---@field isFlyBall boolean ---@field isFlyBall boolean
---@field catchable boolean
---@field xAnimator SimpleAnimator ---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator ---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator
@ -13,10 +14,6 @@
---@field private animatorLib pd_animator_lib ---@field private animatorLib pd_animator_lib
Ball = {} Ball = {}
local function defaultFloatAnimator(animatorLib)
return animatorLib.new(2000, -60, 0, utils.easingHill)
end
---@param animatorLib pd_animator_lib ---@param animatorLib pd_animator_lib
---@return Ball ---@return Ball
function Ball.new(animatorLib) function Ball.new(animatorLib)
@ -25,6 +22,7 @@ function Ball.new(animatorLib)
x = C.Center.x --[[@as number]], x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]], y = C.Center.y --[[@as number]],
z = 0, z = 0,
isFlyBall = false,
catchable = true, catchable = true,
size = C.SmallestBallRadius, size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]], heldBy = nil --[[@type Runner | nil]],
@ -35,26 +33,43 @@ function Ball.new(animatorLib)
-- TODO? Replace these with a ballAnimatorZ? -- TODO? Replace these with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk -- ...that might lose some of the magic of both. Compromise available? idk
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius), sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
floatAnimator = defaultFloatAnimator(animatorLib), floatAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
}, { __index = Ball }) }, { __index = Ball })
end end
---@param deltaSeconds number ---@param deltaSeconds number
function Ball:updatePosition(deltaSeconds) function Ball:updatePosition(deltaSeconds)
if self.heldBy then if self.heldBy then
utils.moveAtSpeedZ(self, 100 * deltaSeconds, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ }) self.x = self.heldBy.x
self.y = self.heldBy.y
self.z = C.GloveZ
self.size = C.SmallestBallRadius self.size = C.SmallestBallRadius
else elseif self.floatAnimator:ended() then
-- Try to bounce?
if self.trajectory then
printTable(self.trajectory)
-- if utils.distanceBetweenPoints(self, self.trajectory) > 1 then
-- self:launch()
-- end
-- local easing = playdate.easingFunctions.outQuint
-- self:launch(self.x + self.trajectory.x, self.y + self.trajectory.y, easing, nil, true)
self.trajectory = nil
end
--else
local lastPos = { x = self.x, y = self.y, z = self.z }
self.x = self.xAnimator:currentValue() self.x = self.xAnimator:currentValue()
local z = self.floatAnimator:currentValue() local z = self.floatAnimator:currentValue()
-- TODO: This `+ z` is more graphics logic than physics logic -- TODO: This `+ z` is more graphics logic than physics logic
self.y = self.yAnimator:currentValue() + z self.y = self.yAnimator:currentValue() + z
self.z = z self.z = z
if self.z < 2 and self.isFlyBall then self.size = self.sizeAnimator:currentValue()
print("Ball hit the ground!") self.trajectory = { x = self.x - lastPos.x, y = self.y - lastPos.y, z = self.z - lastPos.z }
self.isFlyBall = false self.trajectory.x = self.trajectory.x / deltaSeconds
self.trajectory.y = self.trajectory.y / deltaSeconds
self.trajectory.z = self.trajectory.z / deltaSeconds
end end
self.size = C.SmallestBallRadius + math.max(0, (self.floatAnimator:currentValue() - C.GloveZ) / 2) if self.z < 1 then
self.isFlyBall = false
end end
end end
@ -71,11 +86,9 @@ end
---@param easingFunc EasingFunc ---@param easingFunc EasingFunc
---@param flyTimeMs number | nil ---@param flyTimeMs number | nil
---@param floaty boolean | nil ---@param floaty boolean | nil
---@param customFloater pd_animator | nil ---@param customBallScaler pd_animator | nil
---@param isHit boolean function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customFloater, isHit)
self.heldBy = nil self.heldBy = nil
self.isFlyBall = isHit
-- Prevent silly insta-catches -- Prevent silly insta-catches
self:markUncatchable() self:markUncatchable()
@ -84,16 +97,15 @@ function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customFloater,
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
end end
if customFloater then if customBallScaler then
self.floatAnimator = customFloater self.sizeAnimator = customBallScaler
else else
self.sizeAnimator = self.animatorLib.new(flyTimeMs, C.SmallestBallRadius, 9, utils.easingHill) self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
self.floatAnimator = defaultFloatAnimator(self.animatorLib)
end end
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc) self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc) self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
if floaty then if floaty then
self.floatAnimator:reset(flyTimeMs) self.floatAnimator:reset(flyTimeMs / 2)
end end
end end

View File

@ -176,10 +176,6 @@ function Baserunning:pushNewBatter()
return new return new
end end
function Baserunning:getNewestRunner()
return self.runners[#self.runners]
end
---@param runnerIndex number ---@param runnerIndex number
function Baserunning:runnerScored(runnerIndex) function Baserunning:runnerScored(runnerIndex)
self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex] self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex]
@ -205,7 +201,7 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, isAutoRun,
if if
nearestBaseDistance < 5 nearestBaseDistance < 5
and runnerIndex ~= nil and runnerIndex ~= nil
and runner ~= self.batter and runner ~= self.batter --runner.prevBase
and runner.nextBase == C.Bases[C.Home] and runner.nextBase == C.Bases[C.Home]
and nearestBase == C.Bases[C.Home] and nearestBase == C.Bases[C.Home]
then then

View File

@ -1,68 +0,0 @@
---@class BatRenderState
---@field batBase XyPair
---@field batTip XyPair
---@field batAngleDeg number
---@field batSpeed number
---@class Batting
---@field private Baserunning
---@field state BatRenderState Is updated by checkForHit()
Batting = {}
local SwingBackDeg <const> = 30
local SwingForwardDeg <const> = 170
local OffscreenPos <const> = utils.xy(-999, -999)
---@param baserunning Baserunning
function Batting.new(baserunning)
return setmetatable({
baserunning = baserunning,
state = {
batAngleDeg = 0,
batSpeed = 0,
batTip = OffscreenPos,
batBase = OffscreenPos,
},
}, { __index = Batting })
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
--- Assumes the bat is being held by self.baserunning.batter
--- Mutates self.state for later rendering.
---@param batDeg number
---@param batSpeed number
---@param ball Point3d
---@return XyPair | nil, boolean, number | nil Ball destination or nil if no hit, true only if batter swung, power mult
function Batting:checkForHit(batDeg, batSpeed, ball)
local batter = self.baserunning.batter
local isSwinging = batDeg > SwingBackDeg and batDeg < SwingForwardDeg
local batRadians = math.rad(batDeg)
local base = batter and utils.xy(batter.x + C.BatterHandPos.x, batter.y + C.BatterHandPos.y) or OffscreenPos
local tip = utils.xy(base.x + (C.BatLength * math.sin(batRadians)), base.y + (C.BatLength * math.cos(batRadians)))
self.state.batSpeed = batSpeed
self.state.batAngleDeg = batDeg
self.state.batTip = tip
self.state.batBase = base
local ballWasHit = batSpeed > 0 and ball.y < 232 and utils.pointOnOrUnderLine(ball, base, tip, C.Screen.H)
if not ballWasHit then
return nil, isSwinging
end
local ballAngle = batRadians + math.rad(90)
local mult = math.abs(batSpeed / 15)
local ballVelX = mult * C.BattingPower * 10 * math.sin(ballAngle)
local ballVelY = mult * C.BattingPower * 5 * math.cos(ballAngle)
if ballVelY > 0 then
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
return utils.xy(ball.x + ballVelX, ball.y + ballVelY), isSwinging, mult
end

View File

@ -99,7 +99,6 @@ C.Offense = {
running = "running", running = "running",
walking = "walking", walking = "walking",
homeRun = "homeRun", homeRun = "homeRun",
fliedOut = "running",
} }
---@alias Side "offense" | "defense" ---@alias Side "offense" | "defense"
@ -115,8 +114,6 @@ C.CrankPower = 10
C.FielderRunMult = 1.3 C.FielderRunMult = 1.3
C.PlayerHeightOffset = 20
C.UserThrowPower = 0.3 C.UserThrowPower = 0.3
--- How fast baserunners move after a walk --- How fast baserunners move after a walk

View File

@ -1,11 +0,0 @@
local gfx <const> = playdate.graphics
function Ball:draw()
gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(self.x, self.y, self.size)
gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(self.x, self.y, self.size)
end

View File

@ -1,151 +0,0 @@
---@class Characters
---@field homeSprites SpriteCollection
---@field awaySprites SpriteCollection
---@field homeBlipper table
---@field awayBlipper table
Characters = {}
local gfx <const> = playdate.graphics
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
---@param homeSprites SpriteCollection
---@param awaySprites SpriteCollection
function Characters.new(homeSprites, awaySprites)
return setmetatable({
homeSprites = homeSprites,
awaySprites = awaySprites,
homeBlipper = blipper.new(100, homeSprites),
awayBlipper = blipper.new(100, awaySprites),
}, { __index = Characters })
end
---@param ball Point3d
---@param fielderX number
---@param fielderY number
---@return boolean isHoldingBall
local function drawFielderGlove(ball, fielderX, fielderY, flip)
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, flip)
return false
else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
return true
end
end
---@param fieldingTeamSprites SpriteCollection
---@param fielder Fielder
---@param ball Point3d
---@param flip boolean | nil
---@return boolean isHoldingBall
function drawFielder(fieldingTeamSprites, fielder, ball, flip)
local danceOffset = FielderDanceAnimator:currentValue()
local x = fielder.x
local y = fielder.y - danceOffset
fieldingTeamSprites[fielder.spriteIndex].smiling:draw(fielder.x, y - 20, flip)
return drawFielderGlove(ball, x, y)
end
---@param batState BatRenderState
local function drawBat(batState)
gfx.setLineWidth(7)
gfx.drawLine(batState.batBase.x, batState.batBase.y, batState.batTip.x, batState.batTip.y)
gfx.setColor(gfx.kColorWhite)
gfx.setLineCapStyle(gfx.kLineCapStyleRound)
gfx.setLineWidth(3)
gfx.drawLine(batState.batBase.x, batState.batBase.y, batState.batTip.x, batState.batTip.y)
gfx.setColor(gfx.kColorBlack)
end
---@param battingTeamSprites SpriteCollection
---@param batter Runner
---@param batState BatRenderState
local function drawBatter(battingTeamSprites, batter, batState)
local spriteCollection = battingTeamSprites[batter.spriteIndex]
if batState.batAngleDeg > 50 and batState.batAngleDeg < 200 then
drawBat(batState)
spriteCollection.back:draw(batter.x, batter.y - C.PlayerHeightOffset)
else
spriteCollection.smiling:draw(batter.x, batter.y - C.PlayerHeightOffset)
drawBat(batState)
end
end
---@param battingTeam TeamId
---@return SpriteCollection battingTeam, SpriteCollection fieldingTeam, table runnerBlipper
function Characters:getSpriteCollections(battingTeam)
if battingTeam == "home" then
return self.homeSprites, self.awaySprites, self.homeBlipper
end
return self.awaySprites, self.homeSprites, self.awayBlipper
end
---@param fielding Fielding
---@param baserunning Baserunning
---@param batState BatRenderState
---@param battingTeam TeamId
---@param ball Point3d
---@return Fielder | nil ballHeldBy
function Characters:drawAll(fielding, baserunning, batState, battingTeam, ball)
---@type { y: number, drawAction: fun() }[]
local characterDraws = {}
function addDraw(y, drawAction)
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
end
local battingTeamSprites, fieldingTeamSprites, runnerBlipper = self:getSpriteCollections(battingTeam)
---@type Fielder | nil
local ballHeldBy
for _, fielder in pairs(fielding.fielders) do
addDraw(fielder.y, function()
local ballHeldByThisFielder = drawFielder(fieldingTeamSprites, fielder, ball)
if ballHeldByThisFielder then
ballHeldBy = fielder
end
end)
end
for _, runner in pairs(baserunning.runners) do
addDraw(runner.y, function()
local currentBatter = baserunning.batter
if runner == currentBatter then
drawBatter(battingTeamSprites, currentBatter, batState)
else
-- TODO? Change blip speed depending on runner speed?
runnerBlipper:draw(false, runner.x, runner.y - C.PlayerHeightOffset, runner)
end
end)
end
for _, runner in pairs(baserunning.outRunners) do
addDraw(runner.y, function()
battingTeamSprites[runner.spriteIndex].frowning:draw(runner.x, runner.y - C.PlayerHeightOffset)
end)
end
for _, runner in pairs(baserunning.scoredRunners) do
addDraw(runner.y, function()
runnerBlipper:draw(false, runner.x, runner.y - C.PlayerHeightOffset, runner)
end)
end
table.sort(characterDraws, function(a, b)
return a.y < b.y
end)
for _, character in pairs(characterDraws) do
character.drawAction()
end
return ballHeldBy
end
-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
return Characters
end

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

@ -0,0 +1,28 @@
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
---@param ball Point3d
---@param fielderX number
---@param fielderY number
---@return boolean isHoldingBall
local function drawFielderGlove(ball, fielderX, fielderY, flip)
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, flip)
return false
else
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
return true
end
end
---@param playerSprites PlayerImageBundle
---@param ball Point3d
---@param x number
---@param y number
---@return boolean isHoldingBall
function drawFielder(playerSprites, ball, x, y, flip)
playerSprites.smiling:draw(x, y - 20, flip)
return drawFielderGlove(ball, x, y)
end

View File

@ -145,8 +145,7 @@ end
local newStats = stats local newStats = stats
function drawScoreboard(x, y, statistics, outs, battingTeam, inning) function drawScoreboard(x, y, homeScore, awayScore, outs, battingTeam, inning)
local homeScore, awayScore = utils.totalScores(statistics)
if if
newStats.homeScore ~= homeScore newStats.homeScore ~= homeScore
or newStats.awayScore ~= awayScore or newStats.awayScore ~= awayScore

View File

@ -67,8 +67,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.
---@param immediate boolean | nil function Fielding:resetFielderPositions(fromOffTheField)
function Fielding:resetFielderPositions(fromOffTheField, immediate)
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
@ -85,13 +84,6 @@ function Fielding:resetFielderPositions(fromOffTheField, immediate)
self.fielders.left.targets = { utils.xy(C.Screen.W * -0.6, C.Screen.H * -0.1) } 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.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) } self.fielders.right.targets = { utils.xy(C.Screen.W * 1.6, self.fielders.left.targets[1].y) }
if immediate then
for _, fielder in pairs(self.fielders) do
fielder.x = fielder.targets[1].x
fielder.y = fielder.targets[1].y
end
end
end end
---@param deltaSeconds number ---@param deltaSeconds number
@ -104,7 +96,7 @@ local function updateFielderPosition(deltaSeconds, fielder, ball)
local currentTarget = fielder.targets[#fielder.targets] local currentTarget = fielder.targets[#fielder.targets]
local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget) local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget)
if willMove and utils.pointIsAboveLine(nextFielderPos, C.BottomOfOutfieldWall, 40) then if willMove and utils.pointIsSquarelyAboveLine(nextFielderPos, C.BottomOfOutfieldWall) then
local targetCount = #fielder.targets local targetCount = #fielder.targets
-- Back up a little -- Back up a little
fielder.targets[targetCount + 2] = utils.xy(fielder.x, fielder.y + 5) fielder.targets[targetCount + 2] = utils.xy(fielder.x, fielder.y + 5)
@ -133,12 +125,11 @@ end
--- Selects the nearest fielder to move toward the given coordinates. --- Selects the nearest fielder to move toward the given coordinates.
--- Other fielders should attempt to cover their bases --- Other fielders should attempt to cover their bases
---@param ball Point3d ---@param ballDestX number
---@param ballDest XyPair ---@param ballDestY number
function Fielding:haveSomeoneChase(ball, ballDest) function Fielding:haveSomeoneChase(ballDestX, ballDestY)
local chasingFielder = utils.getNearestOf(self.fielders, ballDest.x, ballDest.y) local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY)
-- Start moving toward the ball directly after reaching ballDest chasingFielder.targets = { utils.xy(ballDestX, ballDestY) }
chasingFielder.targets = { ball, ballDest }
for _, base in ipairs(C.Bases) do for _, base in ipairs(C.Bases) do
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder) local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
@ -155,24 +146,19 @@ end
--- **Also updates `ball.heldby`** --- **Also updates `ball.heldby`**
---@param ball Ball ---@param ball Ball
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil, boolean fielderHoldingBall nil if no fielder is currently touching the ball, true if caught a fly ball ---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
function Fielding:updateFielderPositions(ball, deltaSeconds) function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderHoldingBall local fielderHoldingBall
local caughtAFlyBall = false
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
-- TODO: Base this catch on fielder skill? -- TODO: Base this catch on fielder skill?
local canCatch = updateFielderPosition(deltaSeconds, fielder, ball) local canCatch = updateFielderPosition(deltaSeconds, fielder, ball)
if canCatch then if canCatch then
fielderHoldingBall = fielder fielderHoldingBall = fielder
ball.heldBy = fielder -- How much havoc will this wreak? ball.heldBy = fielder -- How much havoc will this wreak?
if ball.isFlyBall then
ball.isFlyBall = false
caughtAFlyBall = true
end
end end
end end
self.fielderHoldingBall = fielderHoldingBall self.fielderHoldingBall = fielderHoldingBall
return fielderHoldingBall, caughtAFlyBall return fielderHoldingBall
end end
-- TODO? Start moving target fielders close sooner? -- TODO? Start moving target fielders close sooner?

View File

@ -136,11 +136,8 @@ function MainMenu:update()
size = 6, size = 6,
} }
local fielder1 = { x = 30, y = 200, spriteIndex = 1 } local ballIsHeld = drawFielder(AwayTeamSpriteGroup[1], ball, 30, 200)
local ballIsHeld = drawFielder(AwayTeamSpriteGroup, fielder1, ball) ballIsHeld = drawFielder(HomeTeamSpriteGroup[2], ball, 350, 200, playdate.graphics.kImageFlippedX) or ballIsHeld
local fielder2 = { x = 350, y = 200, spriteIndex = 2 }
ballIsHeld = drawFielder(HomeTeamSpriteGroup, fielder2, ball, playdate.graphics.kImageFlippedX) or ballIsHeld
if not ballIsHeld then if not ballIsHeld then
gfx.setLineWidth(2) gfx.setLineWidth(2)

View File

@ -15,7 +15,7 @@ import 'CoreLibs/utilities/where.lua'
---@class InputHandler ---@class InputHandler
---@field update fun(self, deltaSeconds: number) ---@field update fun(self, deltaSeconds: number)
---@field updateBatAngle fun(self, ball: Ball, pitchIsOver: boolean, deltaSeconds: number) ---@field updateBat fun(self, ball: Ball, pitchIsOver: boolean, deltaSeconds: number)
---@field runningSpeed fun(self, runner: Runner, ball: Ball) ---@field runningSpeed fun(self, runner: Runner, ball: Ball)
---@field pitch fun(self) ---@field pitch fun(self)
---@field fielderAction fun(self, fielderHoldingBall: Fielder | nil, outedSomeRunner: boolean, ball: Ball) ---@field fielderAction fun(self, fielderHoldingBall: Fielder | nil, outedSomeRunner: boolean, ball: Ball)
@ -40,7 +40,6 @@ import 'action-queue.lua'
import 'announcer.lua' import 'announcer.lua'
import 'ball.lua' import 'ball.lua'
import 'baserunning.lua' import 'baserunning.lua'
import 'batting.lua'
import 'dbg.lua' import 'dbg.lua'
import 'fielding.lua' import 'fielding.lua'
import 'graphics.lua' import 'graphics.lua'
@ -49,14 +48,12 @@ import 'pitching.lua'
import 'statistics.lua' import 'statistics.lua'
import 'user-input.lua' import 'user-input.lua'
import 'draw/ball.lua'
import 'draw/box-score.lua' import 'draw/box-score.lua'
import 'draw/fans.lua' import 'draw/fans.lua'
import 'draw/characters.lua' import 'draw/fielder.lua'
import 'draw/overlay.lua' import 'draw/overlay.lua'
import 'draw/panner.lua' import 'draw/panner.lua'
import 'draw/character-sprites.lua' import 'draw/player.lua'
import 'draw/characters.lua'
import 'draw/throw-meter.lua' import 'draw/throw-meter.lua'
import 'draw/transitions.lua' import 'draw/transitions.lua'
-- stylua: ignore end -- stylua: ignore end
@ -64,9 +61,7 @@ import 'draw/transitions.lua'
-- TODO: Customizable field structure. E.g. stands and ads etc. -- TODO: Customizable field structure. E.g. stands and ads etc.
---@type pd_graphics_lib ---@type pd_graphics_lib
local gfx <const> = playdate.graphics local gfx <const>, C <const> = playdate.graphics, C
local C <const> = C
---@alias Team { benchPosition: XyPair } ---@alias Team { benchPosition: XyPair }
---@type table<TeamId, Team> ---@type table<TeamId, Team>
@ -97,20 +92,24 @@ local teams <const> = {
---@field offenseState OffenseState ---@field offenseState OffenseState
---@field inning number ---@field inning number
---@field stats Statistics ---@field stats Statistics
---@field batBase XyPair
--- Ephemeral data ONLY used during rendering ---@field batTip XyPair
---@class RenderState ---@field batAngleDeg number
---@field bat BatRenderState -- 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.
---@field runnerBlipper Blipper
---@field battingTeamSprites SpriteCollection
---@field fieldingTeamSprites SpriteCollection
---@class Game ---@class Game
---@field private settings Settings ---@field private settings Settings
---@field private announcer Announcer ---@field private announcer Announcer
---@field private fielding Fielding ---@field private fielding Fielding
---@field private baserunning Baserunning ---@field private baserunning Baserunning
---@field private batting Batting
---@field private characters Characters
---@field private npc InputHandler ---@field private npc InputHandler
---@field private userInput InputHandler ---@field private userInput InputHandler
---@field private homeTeamBlipper Blipper
---@field private awayTeamBlipper Blipper
---@field private panner Panner ---@field private panner Panner
---@field private state MutableState ---@field private state MutableState
Game = {} Game = {}
@ -125,16 +124,25 @@ Game = {}
function Game.new(settings, announcer, fielding, baserunning, npc, state) function Game.new(settings, announcer, fielding, baserunning, npc, state)
announcer = announcer or Announcer.new() announcer = announcer or Announcer.new()
fielding = fielding or Fielding.new() fielding = fielding or Fielding.new()
settings.userTeam = "away" settings.userTeam = "home" -- "away"
local homeTeamBlipper = blipper.new(100, settings.homeTeamSpriteGroup)
local awayTeamBlipper = blipper.new(100, settings.awayTeamSpriteGroup)
local battingTeam = "away" local battingTeam = "away"
local runnerBlipper = battingTeam == "away" and awayTeamBlipper or homeTeamBlipper
local ball = Ball.new(gfx.animator) local ball = Ball.new(gfx.animator)
local o = setmetatable({ local o = setmetatable({
settings = settings, settings = settings,
announcer = announcer, announcer = announcer,
fielding = fielding, fielding = fielding,
homeTeamBlipper = homeTeamBlipper,
awayTeamBlipper = awayTeamBlipper,
panner = Panner.new(ball), panner = Panner.new(ball),
state = state or { state = state or {
batBase = utils.xy(C.Center.x - 34, 215),
batTip = utils.xy(0, 0),
batAngleDeg = C.CrankOffsetDeg,
deltaSeconds = 0, deltaSeconds = 0,
ball = ball, ball = ball,
battingTeam = battingTeam, battingTeam = battingTeam,
@ -142,6 +150,9 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
inning = 1, inning = 1,
pitchIsOver = true, pitchIsOver = true,
didSwing = false, didSwing = false,
battingTeamSprites = settings.awayTeamSpriteGroup,
fieldingTeamSprites = settings.homeTeamSpriteGroup,
runnerBlipper = runnerBlipper,
stats = Statistics.new(), stats = Statistics.new(),
}, },
}, { __index = Game }) }, { __index = Game })
@ -149,17 +160,15 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
o.baserunning = baserunning or Baserunning.new(announcer, function() o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning() o:nextHalfInning()
end) end)
o.batting = Batting.new(o.baserunning)
o.userInput = UserInput.new(function(throwFly, forbidThrowHome) o.userInput = UserInput.new(function(throwFly, forbidThrowHome)
return o:buttonControlledThrow(throwFly, forbidThrowHome) return o:buttonControlledThrow(throwFly, forbidThrowHome)
end) end)
o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders) o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)
o.fielding:resetFielderPositions(teams.home.benchPosition, settings.userTeam == nil) o.fielding:resetFielderPositions(teams.home.benchPosition)
playdate.timer.new(settings.userTeam == nil and 10 or 2000, function() playdate.timer.new(2000, function()
o:returnToPitcher() o:returnToPitcher()
end) end)
o.characters = Characters.new(settings.homeTeamSpriteGroup, settings.awayTeamSpriteGroup)
BootTune:play() BootTune:play()
BootTune:setFinishCallback(function() BootTune:setFinishCallback(function()
@ -260,6 +269,8 @@ function Game:pitcherIsReady()
end end
function Game:checkForGameOver() function Game:checkForGameOver()
Fielding.celebrate()
local state = self.state local state = self.state
if state.stats:gameIsOver(state.inning, self.settings.finalInning, state.battingTeam) then if state.stats:gameIsOver(state.inning, self.settings.finalInning, state.battingTeam) then
self.announcer:say("THAT'S THE BALL GAME!") self.announcer:say("THAT'S THE BALL GAME!")
@ -274,8 +285,6 @@ end
function Game:nextHalfInning() function Game:nextHalfInning()
pitchTracker:reset() pitchTracker:reset()
Fielding.celebrate()
if self:checkForGameOver() then if self:checkForGameOver() then
return return
end end
@ -288,8 +297,17 @@ function Game:nextHalfInning()
self.state.inning = self.state.inning + 1 self.state.inning = self.state.inning + 1
self.state.stats:pushInning() self.state.stats:pushInning()
end end
playdate.timer.new(2000, function()
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam) self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
playdate.timer.new(2000, function()
if self.state.battingTeam == "home" then
self.state.battingTeamSprites = self.settings.homeTeamSpriteGroup
self.state.runnerBlipper = self.homeTeamBlipper
self.state.fieldingTeamSprites = self.settings.awayTeamSpriteGroup
else
self.state.battingTeamSprites = self.settings.awayTeamSpriteGroup
self.state.fieldingTeamSprites = self.settings.homeTeamSpriteGroup
self.state.runnerBlipper = self.awayTeamBlipper
end
end) end)
end end
@ -363,67 +381,69 @@ function Game:strikeOut()
self:nextBatter() self:nextBatter()
end end
function Game:saveToFile() local SwingBackDeg <const> = 30
playdate.datastore.write({ currentGame = self.state }, "data", true) local SwingForwardDeg <const> = 170
end
function Game.load()
local loaded = playdate.datastore.read("data")
---@type Game
local loadedGame = loaded.currentGame
loadedGame.state.ball = Ball.new(gfx.animator)
local settings = {
homeTeamSpriteGroup = HomeTeamSpriteGroup,
awayTeamSpriteGroup = AwayTeamSpriteGroup,
finalInning = loadedGame.settings.finalInning,
}
local ret = Game.new(settings, nil, loadedGame.fielding, nil, nil, loadedGame.state)
ret.baserunning.outs = loadedGame.outs
return ret
end
---@param offenseHandler InputHandler ---@param offenseHandler InputHandler
function Game:updateBatting(offenseHandler) function Game:updateBatting(offenseHandler)
local ball = self.state.ball local ball = self.state.ball
local batDeg, batSpeed = offenseHandler:updateBatAngle(ball, self.state.pitchIsOver, self.state.deltaSeconds) local batDeg, batSpeed = offenseHandler:updateBat(ball, self.state.pitchIsOver, self.state.deltaSeconds)
local ballDest, isSwinging, mult = self.batting:checkForHit(batDeg, batSpeed, ball) self.state.batAngleDeg = batDeg
self.state.didSwing = self.state.didSwing or (isSwinging and not self.state.pitchIsOver)
if not ballDest then 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
local batter = self.baserunning.batter
self.state.batBase.x = batter and (batter.x + C.BatterHandPos.x) or -999
self.state.batBase.y = batter and (batter.y + C.BatterHandPos.y) or -999
self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
local ballWasHit = batSpeed > 0
and ball.y < 232
and utils.pointDirectlyUnderLine(ball, self.state.batBase, self.state.batTip, C.Screen.H)
if not ballWasHit then
return return
end end
-- Hit! -- Hit!
-- TODO: animate bat-flip or something
local isFlyBall = math.random() > 0.5
self:saveToFile()
BatCrackReverb:play() BatCrackReverb:play()
self.state.offenseState = C.Offense.running self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15)
local ballVelX = mult * C.BattingPower * 10 * math.sin(ballAngle)
local ballVelY = mult * C.BattingPower * 5 * math.cos(ballAngle)
if ballVelY > 0 then
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
local ballDest = utils.xy(ball.x + ballVelX, ball.y + ballVelY)
pitchTracker:reset() pitchTracker:reset()
local flyTimeMs = 8000 local flyTimeMs = 2000
-- TODO? A dramatic eye-level view on a home-run could be sick. -- TODO? A dramatic eye-level view on a home-run could be sick.
local battingTeamStats = self:battingTeamCurrentInning() local battingTeamStats = self:battingTeamCurrentInning()
battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest
if utils.isFoulBall(ballDest) then if utils.isFoulBall(ballDest.x, ballDest.y) then
self.announcer:say("Foul ball!") self.announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2) pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out -- TODO: Have a fielder chase for the fly-out
return return
end end
local isPastOutfieldWall, nearbyPointAbove = utils.pointIsAboveLine(ballDest, C.OutfieldWall) if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then
if isPastOutfieldWall then
if not isFlyBall then
-- Grounder at the wall!
ballDest.y = nearbyPointAbove.y - 8
else
-- Home run!
playdate.timer.new(flyTimeMs, function() playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted -- Verify that the home run wasn't intercepted
if utils.distanceBetweenPoints(ball, ballDest) < 2 then if utils.within(1, ball.x, ballDest.x) and utils.within(1, ball.y, ballDest.y) then
self.announcer:say("HOME RUN!") self.announcer:say("HOME RUN!")
self.state.offenseState = C.Offense.homeRun self.state.offenseState = C.Offense.homeRun
-- Linger on the home-run ball for a moment, before panning to the bases. -- Linger on the home-run ball for a moment, before panning to the bases.
@ -435,16 +455,12 @@ function Game:updateBatting(offenseHandler)
end end
end) end)
end end
end
local ballHeightAnimator = isFlyBall local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
and gfx.animator.new(flyTimeMs, C.GloveZ, 10 + (2 * mult * mult * 0.5), utils.hitEasingHill) ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler)
or gfx.animator.new(flyTimeMs, 2 * (mult * mult), 0, utils.createBouncer(4))
ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, ballHeightAnimator, true)
self.baserunning:convertBatterToRunner() self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ball, ballDest) self.fielding:haveSomeoneChase(ballDest.x, ballDest.y)
end end
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
@ -487,7 +503,6 @@ function Game:updatePitching(defenseHandler)
end end
if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
self:saveToFile()
local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning()) local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
if outcome == PitchOutcomes.StrikeOut then if outcome == PitchOutcomes.StrikeOut then
self:strikeOut() self:strikeOut()
@ -509,20 +524,12 @@ function Game:updateGameState()
gfx.animation.blinker.updateAll() gfx.animation.blinker.updateAll()
self.state.deltaSeconds = playdate.getElapsedTime() or 0 self.state.deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime() playdate.resetElapsedTime()
self.state.ball:updatePosition() self.state.ball:updatePosition(self.state.deltaSeconds)
local fielderHoldingBall, caughtAFlyBall =
self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if caughtAFlyBall then
local fliedOut = self.baserunning:getNewestRunner()
self.baserunning:outRunner(fliedOut, "Fly out!")
self.state.offenseState = C.Offense.fliedOut
self.baserunning:pushNewBatter()
pitchTracker.secondsSinceLastPitch = -1
end
local offenseHandler, defenseHandler = self:currentInputHandlers() local offenseHandler, defenseHandler = self:currentInputHandlers()
local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if self.state.offenseState == C.Offense.batting then if self.state.offenseState == C.Offense.batting then
self:updatePitching(defenseHandler) self:updatePitching(defenseHandler)
self:updateBatting(offenseHandler) self:updateBatting(offenseHandler)
@ -576,30 +583,111 @@ function Game:update()
gfx.clear() gfx.clear()
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
local state = self.state local offsetX, offsetY = self.panner:get(self.state.deltaSeconds)
local offsetX, offsetY = self.panner:get(state.deltaSeconds)
gfx.setDrawOffset(offsetX, offsetY) gfx.setDrawOffset(offsetX, offsetY)
fans.draw() fans.draw()
GrassBackground:draw(-400, -720) GrassBackground:draw(-400, -720)
local ballHeldBy = ---@type { y: number, drawAction: fun() }[]
self.characters:drawAll(self.fielding, self.baserunning, self.batting.state, state.battingTeam, state.ball) local characterDraws = {}
function addDraw(y, drawAction)
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
end
local ball = self.state.ball
local danceOffset = FielderDanceAnimator:currentValue()
---@type Fielder | nil
local ballHeldBy
for _, fielder in pairs(self.fielding.fielders) do
addDraw(fielder.y + danceOffset, function()
local ballHeldByThisFielder = drawFielder(
self.state.fieldingTeamSprites[fielder.spriteIndex],
ball,
fielder.x,
fielder.y + danceOffset
)
if ballHeldByThisFielder then
ballHeldBy = fielder
end
end)
end
local playerHeightOffset = 20
for _, runner in pairs(self.baserunning.runners) do
addDraw(runner.y, function()
if runner == self.baserunning.batter then
if self.state.batAngleDeg > 50 and self.state.batAngleDeg < 200 then
self.state.battingTeamSprites[runner.spriteIndex].back:draw(runner.x, runner.y - playerHeightOffset)
else
self.state.battingTeamSprites[runner.spriteIndex].smiling:draw(
runner.x,
runner.y - playerHeightOffset
)
end
else
-- TODO? Change blip speed depending on runner speed?
self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset, runner)
end
end)
end
table.sort(characterDraws, function(a, b)
return a.y < b.y
end)
for _, character in pairs(characterDraws) do
character.drawAction()
end
if self.state.offenseState == C.Offense.batting then
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
self.state.battingTeamSprites[runner.spriteIndex].frowning:draw(runner.x, runner.y - playerHeightOffset)
end
for _, runner in pairs(self.baserunning.scoredRunners) do
self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset, runner)
end
if self:userIsOn("defense") then if self:userIsOn("defense") then
throwMeter:drawNearFielder(ballHeldBy) throwMeter:drawNearFielder(ballHeldBy)
end end
if not ballHeldBy then if not ballHeldBy then
state.ball:draw() gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(ball.x, ball.y, ball.size)
gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(ball.x, ball.y, ball.size)
end end
gfx.setDrawOffset(0, 0) gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 20 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(self.baserunning.runners, self.fielding.fielders) drawMinimap(self.baserunning.runners, self.fielding.fielders)
end end
drawScoreboard(0, C.Screen.H * 0.77, state.stats, self.baserunning.outs, state.battingTeam, state.inning) local homeScore, awayScore = utils.totalScores(self.state.stats)
drawScoreboard(
0,
C.Screen.H * 0.77,
homeScore,
awayScore,
self.baserunning.outs,
self.state.battingTeam,
self.state.inning
)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes) drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
self.announcer:draw(C.Center.x, 10) self.announcer:draw(C.Center.x, 10)

View File

@ -1,6 +1,6 @@
local npcBatDeg = 0 local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1000 local BaseNpcBatSpeed <const> = 1500
local npcBatSpeed = BaseNpcBatSpeed local npcBatSpeed = 1500
---@class Npc: InputHandler ---@class Npc: InputHandler
---@field runners Runner[] ---@field runners Runner[]
@ -26,25 +26,19 @@ function Npc.update() end
---@param pitchIsOver boolean ---@param pitchIsOver boolean
---@param deltaSec number ---@param deltaSec number
---@return number batAngleDeg, number batSpeed ---@return number batAngleDeg, number batSpeed
function Npc:updateBatAngle(ball, pitchIsOver, deltaSec) function Npc:updateBat(ball, pitchIsOver, deltaSec)
if if not pitchIsOver and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
not pitchIsOver
and ball.y > 200
and ball.y < 230
and (ball.x < C.Center.x + 15)
and (ball.x > C.Center.x - 12)
then
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed) npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
else else
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
npcBatDeg = utils.moveAtSpeed1d(npcBatDeg, deltaSec * BaseNpcBatSpeed, 230 - 360) npcBatDeg = 230
end end
return npcBatDeg, (self:batSpeed() * deltaSec) return npcBatDeg, (self:batSpeed() * deltaSec)
end end
---@return number ---@return number
function Npc:batSpeed() function Npc:batSpeed()
return npcBatSpeed * 1.25 return npcBatSpeed / 1.5
end end
---@return number flyTimeMs, number pitchId, number accuracy ---@return number flyTimeMs, number pitchId, number accuracy
@ -137,10 +131,7 @@ end
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall } ---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
local function tryToMakeAPlay(fielders, fielder, runners, ball) local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners) local targetX, targetY = getNextOutTarget(runners)
if targetX == nil or targetY == nil then if targetX ~= nil and targetY ~= nil then
return
end
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
nearestFielder.targets = { utils.xy(targetX, targetY) } nearestFielder.targets = { utils.xy(targetX, targetY) }
if nearestFielder == fielder then if nearestFielder == fielder then
@ -148,6 +139,7 @@ local function tryToMakeAPlay(fielders, fielder, runners, ball)
else else
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true) ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
end end
end
end end
---@param fielder Fielder ---@param fielder Fielder
@ -157,14 +149,14 @@ function Npc:fielderAction(fielder, outedSomeRunner, ball)
if not fielder then if not fielder then
return return
end end
local playDelay = outedSomeRunner and 0.5 or 0.1 if outedSomeRunner then
actionQueue:newOnly("npcFielderAction", 2000, function() -- Delay a little before the next play
local dt = 0 playdate.timer.new(750, function()
while dt < playDelay do
dt = dt + coroutine.yield()
end
tryToMakeAPlay(self.fielders, fielder, self.runners, ball) tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end) end)
else
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end
end end
---@return number ---@return number

View File

@ -1,5 +1,5 @@
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch fun(accuracy: number, ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } ---@alias Pitch fun(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
---@type pd_graphics_lib ---@type pd_graphics_lib
local gfx <const> = playdate.graphics local gfx <const> = playdate.graphics

View File

@ -13,13 +13,6 @@ import = function(target)
require(target:sub(1, #target - 4)) require(target:sub(1, #target - 4))
end end
Glove = {
getSize = function()
return 10, 10
end,
}
Characters = require("draw/characters")
local Game = require("main") local Game = require("main")
local settings = { local settings = {

View File

@ -11,16 +11,14 @@ end
function UserInput:update() function UserInput:update()
self.crankChange = playdate.getCrankChange() self.crankChange = playdate.getCrankChange()
self.crankLimited = self.crankChange == 0 and 0 or (math.log(math.abs(self.crankChange)) * C.CrankPower) local crankLimited = self.crankChange == 0 and 0 or (math.log(math.abs(self.crankChange)) * C.CrankPower)
if self.crankChange < 0 then self.crankLimited = math.abs(crankLimited)
self.crankLimited = self.crankLimited * -1
end
end end
---@return number batAngleDeg, number batSpeed ---@return number batAngleDeg, number batSpeed
function UserInput:updateBatAngle() function UserInput:updateBat()
local batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 local batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
local batSpeed = math.abs(self.crankLimited) local batSpeed = self.crankLimited
return batAngleDeg, batSpeed return batAngleDeg, batSpeed
end end

View File

@ -1,14 +1,16 @@
-- luacheck no new globals -- luacheck no new globals
utils = {} utils = {}
---@class XyPair --- @alias XyPair {
---@field x: number, --- x: number,
---@field y: number, --- y: number,
--- }
---@class Point3d --- @alias Point3d {
---@field x number, --- x: number,
---@field y number, --- y: number,
---@field z number, --- z: number,
--- }
local sqrt <const> = math.sqrt local sqrt <const> = math.sqrt
@ -20,25 +22,6 @@ function utils.easingHill(t, b, c, d)
return (c * t) + b return (c * t) + b
end end
function utils.hitEasingHill(t, b, c, d)
c = c + 0.0 -- convert to float to prevent integer overflow
t = 1 - (t / d)
local extraDrop = -C.GloveZ * t
t = ((t * 2) - 1)
t = t * t
return (c * t) + b + extraDrop
end
function utils.createBouncer(bounceCount)
return function(t, b, c, d)
c = c + 0.0 -- convert to float to prevent integer overflow
local percComplete = t / d
local x = percComplete * math.pi * bounceCount
local weird = -math.abs((2 / x) * math.sin(x)) / math.pi * 2
return b + c + (c * weird)
end
end
--- @alias StaticAnimator { --- @alias StaticAnimator {
--- currentValue: fun(self): number; --- currentValue: fun(self): number;
--- reset: fun(self, durationMs: number | nil); --- reset: fun(self, durationMs: number | nil);
@ -82,29 +65,6 @@ function utils.normalizeVector(x1, y1, x2, y2)
return x / distance, y / distance, distance return x / distance, y / distance, distance
end end
function utils.normalizeVectorZ(x1, y1, z1, x2, y2, z2)
local distance, x, y, z = utils.distanceBetweenZ(x1, y1, z1, x2, y2, z2)
return x / distance, y / distance, z / distance, distance
end
---@param current number
---@param speed number Must not be negative!
---@param target number
---@return number newValue, boolean didMove
function utils.moveAtSpeed1d(current, speed, target)
local distance = math.abs(current - target)
if distance == 0 then
return target, false
end
if distance < speed then
return target, true
end
if target > current then
return current + speed, true
end
return current - speed, true
end
--- Push the given obect at the given speed toward a target. Speed should be pre-multiplied by the frame's delta time. --- Push the given obect at the given speed toward a target. Speed should be pre-multiplied by the frame's delta time.
--- Stops when within 1. Returns true only if the object did actually move. --- Stops when within 1. Returns true only if the object did actually move.
---@param mover { x: number, y: number } ---@param mover { x: number, y: number }
@ -130,29 +90,12 @@ function utils.moveAtSpeed(mover, speed, target, tau)
return true return true
end end
---@param mover Point3d ---@param acceptableGap number
---@param speed number ---@param n1 number
---@param target Point3d ---@param n2 number
---@param tau number | nil ---@return boolean n1 is within acceptableGap of n2
---@return boolean isStillMoving function utils.within(acceptableGap, n1, n2)
function utils.moveAtSpeedZ(mover, speed, target, tau) return math.abs(n1 - n2) < acceptableGap
local x, y, distance = utils.normalizeVectorZ(mover.x, mover.y, mover.z, target.x, target.y, target.z)
if distance == 0 then
return false
end
if distance > (tau or 1) then
mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed)
mover.z = mover.z - (z * speed)
else
mover.x = target.x
mover.y = target.y
mover.z = target.z
end
return true
end end
---@generic T ---@generic T
@ -240,41 +183,30 @@ end
---@param line2 XyPair ---@param line2 XyPair
---@param bottomBound number ---@param bottomBound number
---@return boolean ---@return boolean
function utils.pointOnOrUnderLine(point, line1, line2, bottomBound) function utils.pointDirectlyUnderLine(point, line1, line2, 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
if point.x < line1.x or point.x > line2.x or point.y > bottomBound then if point.x < line1.x or point.x > line2.x or point.y > bottomBound then
return false return false
end end
return utils.pointUnderLine(point.x, point.y, line1.x, line1.y - 2, line2.x, line2.y - 2) return utils.pointUnderLine(point.x, point.y, line1.x, line1.y, line2.x, line2.y)
end end
--- Returns true if the given point is anywhere above the given line, with no upper bound. --- Returns true if the given point is anywhere above the given line, with no upper bound.
--- This, used for home run calculations, does not *precesely* take into account balls that curve around the foul poles. --- This, if used for home run calculations, would not take into account balls that curve around the foul poles.
--- If left of first linePoint and above it, returns true. Similarly if right of the last linePoint.
---@param point XyPair ---@param point XyPair
---@param linePoints XyPair[] ---@param linePoints XyPair[]
---@return boolean, XyPair | nil nearbyPointAbove ---@return boolean
function utils.pointIsAboveLine(point, linePoints, by) function utils.pointIsSquarelyAboveLine(point, linePoints)
by = by or 0
local pointY = point.y + by
if point.x < linePoints[1].x and pointY < linePoints[1].y then
return true, linePoints[1]
end
for i = 2, #linePoints do for i = 2, #linePoints do
local prev = linePoints[i - 1] local prev = linePoints[i - 1]
local next = linePoints[i] local next = linePoints[i]
if point.x >= prev.x and point.x <= next.x then if point.x >= prev.x and point.x <= next.x then
if not utils.pointUnderLine(point.x, pointY, prev.x, prev.y, next.x, next.y) then return not utils.pointUnderLine(point.x, point.y, prev.x, prev.y, next.x, next.y)
return true, prev
end end
end end
end return false
if point.x > linePoints[#linePoints].x and pointY < linePoints[#linePoints].y then
return true, linePoints[#linePoints]
end
return false, nil
end end
--- Returns true only if the point is below the given line. --- Returns true only if the point is below the given line.
@ -299,13 +231,14 @@ function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
end end
--- Returns true if a ball landing at destX,destY will be foul. --- Returns true if a ball landing at destX,destY will be foul.
---@param dest XyPair ---@param destX number
function utils.isFoulBall(dest) ---@param destY number
function utils.isFoulBall(destX, destY)
local leftLine = C.LeftFoulLine local leftLine = C.LeftFoulLine
local rightLine = C.RightFoulLine local rightLine = C.RightFoulLine
return utils.pointUnderLine(dest.x, dest.y, leftLine.x1, leftLine.y1, leftLine.x2, leftLine.y2) return utils.pointUnderLine(destX, destY, leftLine.x1, leftLine.y1, leftLine.x2, leftLine.y2)
or utils.pointUnderLine(dest.x, dest.y, rightLine.x1, rightLine.y1, rightLine.x2, rightLine.y2) or utils.pointUnderLine(destX, destY, rightLine.x1, rightLine.y1, rightLine.x2, rightLine.y2)
end end
--- Returns the nearest position object from the given point, as well as its distance from that point --- Returns the nearest position object from the given point, as well as its distance from that point