Compare commits

...

8 Commits

Author SHA1 Message Date
Sage Vaillancourt 4b9a94c2c2 Implement grounders and fly-outs.
Add a slight delay on npc fielder actions.
Speed up intro when userTeam == nil
2025-03-01 12:59:12 -05:00
Sage Vaillancourt decd1f7080 Fix inverted dance animation
Switch XyPair and Point3d to @class, over @alias
Seems to work better with autocomplete
2025-03-01 09:54:05 -05:00
Sage Vaillancourt 04d25127fc Extract batting.lua 2025-02-27 19:31:00 -05:00
Sage Vaillancourt 8b66e2e826 Drop within(). Just use distance between 2025-02-26 14:51:54 -05:00
Sage Vaillancourt 876f828117 Extract draw/ball.lua
Tweak pointIsSquarelyAboveLine() -> pointIsAboveLine()
2025-02-26 14:20:36 -05:00
Sage Vaillancourt ce9a2d335e Fix to re-enable user backwards baserunning 2025-02-26 13:14:19 -05:00
Sage Vaillancourt 80015dbe62 Extract draw/characters.lua
Pulls a bunch of draw logic out of main.lua; handles z-ordering.
Expand on save/load - though it's certainly not complete yet.
2025-02-26 13:04:38 -05:00
Sage Vaillancourt 176a7e6d5e Add saveToFile()
Not *likely* to work yet, but start scoping out good times to make a save.
Also, correct the Pitch type.
2025-02-25 17:14:47 -05:00
17 changed files with 522 additions and 288 deletions

View File

@ -5,6 +5,7 @@
---@field size number ---@field size number
---@field heldBy Fielder | nil ---@field heldBy Fielder | nil
---@field catchable boolean ---@field catchable boolean
---@field isFlyBall boolean
---@field xAnimator SimpleAnimator ---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator ---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator
@ -12,6 +13,10 @@
---@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)
@ -30,15 +35,14 @@ 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 = animatorLib.new(2000, -60, 0, utils.easingHill), floatAnimator = defaultFloatAnimator(animatorLib),
}, { __index = Ball }) }, { __index = Ball })
end end
function Ball:updatePosition() ---@param deltaSeconds number
function Ball:updatePosition(deltaSeconds)
if self.heldBy then if self.heldBy then
self.x = self.heldBy.x utils.moveAtSpeedZ(self, 100 * deltaSeconds, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ })
self.y = self.heldBy.y
self.z = C.GloveZ
self.size = C.SmallestBallRadius self.size = C.SmallestBallRadius
else else
self.x = self.xAnimator:currentValue() self.x = self.xAnimator:currentValue()
@ -46,7 +50,11 @@ function Ball:updatePosition()
-- 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
self.size = self.sizeAnimator:currentValue() if self.z < 2 and self.isFlyBall then
print("Ball hit the ground!")
self.isFlyBall = false
end
self.size = C.SmallestBallRadius + math.max(0, (self.floatAnimator:currentValue() - C.GloveZ) / 2)
end end
end end
@ -63,9 +71,11 @@ end
---@param easingFunc EasingFunc ---@param easingFunc EasingFunc
---@param flyTimeMs number | nil ---@param flyTimeMs number | nil
---@param floaty boolean | nil ---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil ---@param customFloater pd_animator | nil
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) ---@param isHit boolean
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()
@ -74,10 +84,11 @@ function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScal
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 customBallScaler then if customFloater then
self.sizeAnimator = customBallScaler self.floatAnimator = customFloater
else else
self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill) self.sizeAnimator = self.animatorLib.new(flyTimeMs, C.SmallestBallRadius, 9, 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)

View File

@ -176,6 +176,10 @@ 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]
@ -201,7 +205,7 @@ function Baserunning:updateRunner(runner, runnerIndex, appliedSpeed, isAutoRun,
if if
nearestBaseDistance < 5 nearestBaseDistance < 5
and runnerIndex ~= nil and runnerIndex ~= nil
and runner ~= self.batter --runner.prevBase and runner ~= self.batter
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

68
src/batting.lua Normal file
View File

@ -0,0 +1,68 @@
---@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,6 +99,7 @@ C.Offense = {
running = "running", running = "running",
walking = "walking", walking = "walking",
homeRun = "homeRun", homeRun = "homeRun",
fliedOut = "running",
} }
---@alias Side "offense" | "defense" ---@alias Side "offense" | "defense"
@ -114,6 +115,8 @@ 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

11
src/draw/ball.lua Normal file
View File

@ -0,0 +1,11 @@
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

151
src/draw/characters.lua Normal file
View File

@ -0,0 +1,151 @@
---@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

View File

@ -1,28 +0,0 @@
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,7 +145,8 @@ end
local newStats = stats local newStats = stats
function drawScoreboard(x, y, homeScore, awayScore, outs, battingTeam, inning) function drawScoreboard(x, y, statistics, 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,7 +67,8 @@ 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 Fielding:resetFielderPositions(fromOffTheField) ---@param immediate boolean | nil
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
@ -84,6 +85,13 @@ function Fielding:resetFielderPositions(fromOffTheField)
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
@ -96,7 +104,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.pointIsSquarelyAboveLine(nextFielderPos, C.BottomOfOutfieldWall) then if willMove and utils.pointIsAboveLine(nextFielderPos, C.BottomOfOutfieldWall, 40) 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)
@ -125,11 +133,12 @@ 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 ballDestX number ---@param ball Point3d
---@param ballDestY number ---@param ballDest XyPair
function Fielding:haveSomeoneChase(ballDestX, ballDestY) function Fielding:haveSomeoneChase(ball, ballDest)
local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY) local chasingFielder = utils.getNearestOf(self.fielders, ballDest.x, ballDest.y)
chasingFielder.targets = { utils.xy(ballDestX, ballDestY) } -- Start moving toward the ball directly after reaching ballDest
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)
@ -146,19 +155,24 @@ end
--- **Also updates `ball.heldby`** --- **Also updates `ball.heldby`**
---@param ball Ball ---@param ball Ball
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball ---@return Fielder | nil, boolean fielderHoldingBall nil if no fielder is currently touching the ball, true if caught a fly 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 return fielderHoldingBall, caughtAFlyBall
end end
-- TODO? Start moving target fielders close sooner? -- TODO? Start moving target fielders close sooner?

View File

@ -136,8 +136,11 @@ function MainMenu:update()
size = 6, size = 6,
} }
local ballIsHeld = drawFielder(AwayTeamSpriteGroup[1], ball, 30, 200) local fielder1 = { x = 30, y = 200, spriteIndex = 1 }
ballIsHeld = drawFielder(HomeTeamSpriteGroup[2], ball, 350, 200, playdate.graphics.kImageFlippedX) or ballIsHeld local ballIsHeld = drawFielder(AwayTeamSpriteGroup, fielder1, ball)
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 updateBat fun(self, ball: Ball, pitchIsOver: boolean, deltaSeconds: number) ---@field updateBatAngle 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,6 +40,7 @@ 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'
@ -48,12 +49,14 @@ 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/fielder.lua' import 'draw/characters.lua'
import 'draw/overlay.lua' import 'draw/overlay.lua'
import 'draw/panner.lua' import 'draw/panner.lua'
import 'draw/player.lua' import 'draw/character-sprites.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
@ -61,7 +64,9 @@ 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>, C <const> = playdate.graphics, C local gfx <const> = playdate.graphics
local C <const> = C
---@alias Team { benchPosition: XyPair } ---@alias Team { benchPosition: XyPair }
---@type table<TeamId, Team> ---@type table<TeamId, Team>
@ -92,24 +97,20 @@ local teams <const> = {
---@field offenseState OffenseState ---@field offenseState OffenseState
---@field inning number ---@field inning number
---@field stats Statistics ---@field stats Statistics
---@field batBase XyPair
---@field batTip XyPair --- Ephemeral data ONLY used during rendering
---@field batAngleDeg number ---@class RenderState
-- These are only sort-of global state. They are purely graphical, ---@field bat BatRenderState
-- 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 = {}
@ -124,25 +125,16 @@ 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 = nil -- "away" settings.userTeam = "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,
@ -150,9 +142,6 @@ 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 })
@ -160,15 +149,17 @@ 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) o.fielding:resetFielderPositions(teams.home.benchPosition, settings.userTeam == nil)
playdate.timer.new(2000, function() playdate.timer.new(settings.userTeam == nil and 10 or 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()
@ -269,8 +260,6 @@ 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!")
@ -285,6 +274,8 @@ 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
@ -297,17 +288,8 @@ 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
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
if self.state.battingTeam == "home" then self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
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
@ -381,86 +363,88 @@ function Game:strikeOut()
self:nextBatter() self:nextBatter()
end end
local SwingBackDeg <const> = 30 function Game:saveToFile()
local SwingForwardDeg <const> = 170 playdate.datastore.write({ currentGame = self.state }, "data", true)
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:updateBat(ball, self.state.pitchIsOver, self.state.deltaSeconds) local batDeg, batSpeed = offenseHandler:updateBatAngle(ball, self.state.pitchIsOver, self.state.deltaSeconds)
self.state.batAngleDeg = batDeg local ballDest, isSwinging, mult = self.batting:checkForHit(batDeg, batSpeed, ball)
self.state.didSwing = self.state.didSwing or (isSwinging and not self.state.pitchIsOver)
if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then if not ballDest 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 = 2000 local flyTimeMs = 8000
-- 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.x, ballDest.y) then if utils.isFoulBall(ballDest) 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
if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then local isPastOutfieldWall, nearbyPointAbove = utils.pointIsAboveLine(ballDest, C.OutfieldWall)
playdate.timer.new(flyTimeMs, function()
-- Verify that the home run wasn't intercepted if isPastOutfieldWall then
if utils.within(1, ball.x, ballDest.x) and utils.within(1, ball.y, ballDest.y) then if not isFlyBall then
self.announcer:say("HOME RUN!") -- Grounder at the wall!
self.state.offenseState = C.Offense.homeRun ballDest.y = nearbyPointAbove.y - 8
-- Linger on the home-run ball for a moment, before panning to the bases. else
playdate.timer.new(1000, function() -- Home run!
self.panner:panTo(utils.xy(0, 0), function() playdate.timer.new(flyTimeMs, function()
return self:pitcherIsReady() -- Verify that the home run wasn't intercepted
if utils.distanceBetweenPoints(ball, ballDest) < 2 then
self.announcer:say("HOME RUN!")
self.state.offenseState = C.Offense.homeRun
-- Linger on the home-run ball for a moment, before panning to the bases.
playdate.timer.new(1000, function()
self.panner:panTo(utils.xy(0, 0), function()
return self:pitcherIsReady()
end)
end) end)
end) end
end end)
end) end
end end
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) local ballHeightAnimator = isFlyBall
ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler) and gfx.animator.new(flyTimeMs, C.GloveZ, 10 + (2 * mult * mult * 0.5), utils.hitEasingHill)
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(ballDest.x, ballDest.y) self.fielding:haveSomeoneChase(ball, ballDest)
end end
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
@ -503,6 +487,7 @@ 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()
@ -526,9 +511,17 @@ function Game:updateGameState()
playdate.resetElapsedTime() playdate.resetElapsedTime()
self.state.ball:updatePosition() self.state.ball:updatePosition()
local offenseHandler, defenseHandler = self:currentInputHandlers() 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 fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) local offenseHandler, defenseHandler = self:currentInputHandlers()
if self.state.offenseState == C.Offense.batting then if self.state.offenseState == C.Offense.batting then
self:updatePitching(defenseHandler) self:updatePitching(defenseHandler)
@ -583,111 +576,30 @@ function Game:update()
gfx.clear() gfx.clear()
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
local offsetX, offsetY = self.panner:get(self.state.deltaSeconds) local state = self.state
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)
---@type { y: number, drawAction: fun() }[] local ballHeldBy =
local characterDraws = {} self.characters:drawAll(self.fielding, self.baserunning, self.batting.state, state.battingTeam, state.ball)
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
gfx.setLineWidth(2) state.ball:draw()
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) > 10 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 20 then
drawMinimap(self.baserunning.runners, self.fielding.fielders) drawMinimap(self.baserunning.runners, self.fielding.fielders)
end end
local homeScore, awayScore = utils.totalScores(self.state.stats) drawScoreboard(0, C.Screen.H * 0.77, state.stats, self.baserunning.outs, state.battingTeam, state.inning)
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> = 1500 local BaseNpcBatSpeed <const> = 1000
local npcBatSpeed = 1500 local npcBatSpeed = BaseNpcBatSpeed
---@class Npc: InputHandler ---@class Npc: InputHandler
---@field runners Runner[] ---@field runners Runner[]
@ -26,19 +26,25 @@ 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:updateBat(ball, pitchIsOver, deltaSec) function Npc:updateBatAngle(ball, pitchIsOver, deltaSec)
if not pitchIsOver and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then if
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 = 230 npcBatDeg = utils.moveAtSpeed1d(npcBatDeg, deltaSec * BaseNpcBatSpeed, 230 - 360)
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.5 return npcBatSpeed * 1.25
end end
---@return number flyTimeMs, number pitchId, number accuracy ---@return number flyTimeMs, number pitchId, number accuracy
@ -131,14 +137,16 @@ 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 and targetY ~= nil then if targetX == nil or targetY == nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) return
nearestFielder.targets = { utils.xy(targetX, targetY) } end
if nearestFielder == fielder then
ball.heldBy = fielder local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
else nearestFielder.targets = { utils.xy(targetX, targetY) }
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true) if nearestFielder == fielder then
end ball.heldBy = fielder
else
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
end end
end end
@ -149,14 +157,14 @@ function Npc:fielderAction(fielder, outedSomeRunner, ball)
if not fielder then if not fielder then
return return
end end
if outedSomeRunner then local playDelay = outedSomeRunner and 0.5 or 0.1
-- Delay a little before the next play actionQueue:newOnly("npcFielderAction", 2000, function()
playdate.timer.new(750, function() local dt = 0
tryToMakeAPlay(self.fielders, fielder, self.runners, ball) while dt < playDelay do
end) dt = dt + coroutine.yield()
else end
tryToMakeAPlay(self.fielders, fielder, self.runners, ball) tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end 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(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } ---@alias Pitch fun(accuracy: number, 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,6 +13,13 @@ 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,14 +11,16 @@ end
function UserInput:update() function UserInput:update()
self.crankChange = playdate.getCrankChange() self.crankChange = playdate.getCrankChange()
local crankLimited = self.crankChange == 0 and 0 or (math.log(math.abs(self.crankChange)) * C.CrankPower) self.crankLimited = self.crankChange == 0 and 0 or (math.log(math.abs(self.crankChange)) * C.CrankPower)
self.crankLimited = math.abs(crankLimited) if self.crankChange < 0 then
self.crankLimited = self.crankLimited * -1
end
end end
---@return number batAngleDeg, number batSpeed ---@return number batAngleDeg, number batSpeed
function UserInput:updateBat() function UserInput:updateBatAngle()
local batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 local batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
local batSpeed = self.crankLimited local batSpeed = math.abs(self.crankLimited)
return batAngleDeg, batSpeed return batAngleDeg, batSpeed
end end

View File

@ -1,16 +1,14 @@
-- luacheck no new globals -- luacheck no new globals
utils = {} utils = {}
--- @alias XyPair { ---@class XyPair
--- x: number, ---@field x: number,
--- y: number, ---@field y: number,
--- }
--- @alias Point3d { ---@class Point3d
--- x: number, ---@field x number,
--- y: number, ---@field y number,
--- z: number, ---@field z number,
--- }
local sqrt <const> = math.sqrt local sqrt <const> = math.sqrt
@ -22,6 +20,25 @@ 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);
@ -65,6 +82,29 @@ 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 }
@ -90,12 +130,29 @@ function utils.moveAtSpeed(mover, speed, target, tau)
return true return true
end end
---@param acceptableGap number ---@param mover Point3d
---@param n1 number ---@param speed number
---@param n2 number ---@param target Point3d
---@return boolean n1 is within acceptableGap of n2 ---@param tau number | nil
function utils.within(acceptableGap, n1, n2) ---@return boolean isStillMoving
return math.abs(n1 - n2) < acceptableGap function utils.moveAtSpeedZ(mover, speed, target, tau)
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
@ -183,30 +240,41 @@ end
---@param line2 XyPair ---@param line2 XyPair
---@param bottomBound number ---@param bottomBound number
---@return boolean ---@return boolean
function utils.pointDirectlyUnderLine(point, line1, line2, bottomBound) function utils.pointOnOrUnderLine(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, line2.x, line2.y) return utils.pointUnderLine(point.x, point.y, line1.x, line1.y - 2, line2.x, line2.y - 2)
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, if used for home run calculations, would not take into account balls that curve around the foul poles. --- This, used for home run calculations, does not *precesely* 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 ---@return boolean, XyPair | nil nearbyPointAbove
function utils.pointIsSquarelyAboveLine(point, linePoints) function utils.pointIsAboveLine(point, linePoints, by)
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
return not utils.pointUnderLine(point.x, point.y, prev.x, prev.y, next.x, next.y) if not utils.pointUnderLine(point.x, pointY, prev.x, prev.y, next.x, next.y) then
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.
@ -231,14 +299,13 @@ 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 destX number ---@param dest XyPair
---@param destY number function utils.isFoulBall(dest)
function utils.isFoulBall(destX, destY)
local leftLine = C.LeftFoulLine local leftLine = C.LeftFoulLine
local rightLine = C.RightFoulLine local rightLine = C.RightFoulLine
return utils.pointUnderLine(destX, destY, leftLine.x1, leftLine.y1, leftLine.x2, leftLine.y2) return utils.pointUnderLine(dest.x, dest.y, leftLine.x1, leftLine.y1, leftLine.x2, leftLine.y2)
or utils.pointUnderLine(destX, destY, rightLine.x1, rightLine.y1, rightLine.x2, rightLine.y2) or utils.pointUnderLine(dest.x, dest.y, 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