Compare commits

...

1 Commits

Author SHA1 Message Date
Sage Vaillancourt 3e256d95c9 Committing my "realer" physics changes as they stand
Which is to say, *very, very, unplayable*
2025-02-16 18:34:45 -05:00
12 changed files with 362 additions and 121 deletions

View File

@ -1,14 +1,18 @@
---@class Ball ---@class Ball
---@field x number ---@field x number
---@field y number ---@field y number
---@field xVelocity number
---@field yVelocity number
---@field z number ---@field z number
---@field flyBall boolean
---@field size number ---@field size number
---@field heldBy Fielder | nil ---@field heldBy Fielder | nil
---@field xAnimator SimpleAnimator ---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator ---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator
---@field floatAnimator SimpleAnimator ---@field zAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib ---@field private animatorLib pd_animator_lib
---@field private bounce thread Requires deltaSeconds on resume, returns ball height.
---@field private flyTimeMs number ---@field private flyTimeMs number
Ball = {} Ball = {}
@ -17,9 +21,10 @@ Ball = {}
function Ball.new(animatorLib) function Ball.new(animatorLib)
return setmetatable({ return setmetatable({
animatorLib = animatorLib, animatorLib = animatorLib,
x = C.Center.x --[[@as number]], x = 400 --[[@as number]],
y = C.Center.y --[[@as number]], y = 300 --[[@as number]],
z = 0, z = 0,
flyBall = false,
size = C.SmallestBallRadius, size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]], heldBy = nil --[[@type Runner | nil]],
@ -29,52 +34,184 @@ 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), zAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
}, { __index = Ball }) }, { __index = Ball })
end end
function Ball:updatePosition() ---@param fielder Fielder
if self.heldBy then function Ball:caughtBy(fielder)
self.x = self.heldBy.x self.heldBy = fielder
self.y = self.heldBy.y -- if self.flyBall then
self.z = C.GloveZ -- TODO: Signal an out if the ball hasn't touched the ground.
self.size = C.SmallestBallRadius -- end
else end
self.x = self.xAnimator:currentValue()
local z = self.floatAnimator:currentValue() function timeToFirstBounce(v)
-- TODO: This `+ z` is more graphics logic than physics logic local h0 = 0.1 -- m/s
self.y = self.yAnimator:currentValue() + z -- local v = 10 -- m/s, current velocity
self.z = z local g = 10 -- m/s/s
self.size = self.sizeAnimator:currentValue() local t = 0 -- starting time
local rho = 0.75 -- coefficient of restitution
local tau = 0.10 -- contact time for bounce
local hmax = h0 -- keep track of the maximum height
local h = h0
local hstop = 0.01 -- stop when bounce is less than 1 cm
local freefall = true -- state: freefall or in contact
local vmax = math.sqrt(2 * hmax * g)
local dt = 0.1
local ret = 0
while hmax > hstop do
if freefall then
local hnew = h + v * dt - 0.5 * g * dt * dt
if hnew < 0 then
return ret
else
t = t + dt
v = v - g * dt
h = hnew
end
else
t = t + tau
vmax = vmax * rho
v = vmax
freefall = true
h = 0
end
hmax = 0.5 * vmax * vmax / g
ret = ret + dt
end
return 0
end
function bouncer(v, ball)
printTable(ball)
local startMs = playdate.getCurrentTimeMilliseconds()
return function()
local h0 = 0.1 -- m/s
-- local v = 10 -- m/s, current velocity
local g = 10 -- m/s/s
local t = 0 -- starting time
local rho = 0.60 -- coefficient of restitution
local tau = 0.10 -- contact time for bounce
local hmax = h0 -- keep track of the maximum height
local h = h0
local hstop = 0.01 -- stop when bounce is less than 1 cm
local freefall = true -- state: freefall or in contact
local t_last = -math.sqrt(2 * h0 / g) -- time we would have launched to get to h0 at t=0
local vmax = math.sqrt(v * g)
while hmax > hstop do
local dt = coroutine.yield(h, not freefall)
if freefall then
local hnew = h + v * dt - 0.5 * g * dt * dt
if hnew < 0 then
-- Bounced!
t = t_last + 2 * math.sqrt(2 * hmax / g)
freefall = false
t_last = t + tau
h = 0
else
t = t + dt
v = v - g * dt
h = hnew
end
else
t = t + tau
vmax = vmax * rho
v = vmax
freefall = true
h = 0
end
hmax = 0.5 * vmax * vmax / g
end
return 0
end end
end end
---@param deltaSeconds number
function Ball:updatePosition(deltaSeconds)
-- printTable({ x = self.x, y = self.y, z = self.z })
if self.heldBy then
utils.moveAtSpeedZ(self, deltaSeconds * 10, { 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
else
-- self.x = self.x + self.xVelocity
-- self.x = self.xAnimator:currentValue()
-- self.y = self.yAnimator:currentValue()
-- self.z = self.zAnimator:currentValue()
if self.bounce then
local alive, z, justBounced = coroutine.resume(self.bounce, deltaSeconds)
if alive then
local lostVelMult = justBounced and 0.8 or 0.99
self.xVelocity = self.xVelocity * (1 - (deltaSeconds * lostVelMult))
self.yVelocity = self.yVelocity * (1 - (deltaSeconds * lostVelMult))
self.x = self.x + (self.xVelocity * deltaSeconds)
self.y = self.y + (self.yVelocity * deltaSeconds)
self.z = z * 30
else
self.bounce = nil
end
end
self.size = (math.max(0, 0.04 * (self.z - C.GloveZ))) + C.SmallestBallRadius
-- print(self.size)
end
if self.z < 1 then
self.flyBall = false
end
end
---@alias DestinationAndFlightTime { destX: number, destY: number, flyTimeMs: number }
---@alias LaunchControls DestinationAndFlightTime
--- Launches the ball from its current position to the given destination. --- Launches the ball from its current position to the given destination.
---@param destX number ---@param destX number
---@param destY number ---@param destY number
---@param easingFunc EasingFunc ---@param easingFunc EasingFunc
---@param flyTimeMs number | nil ---@param freshHit boolean | nil
---@param floaty boolean | nil ---@param flyTimeMs number | nil The angle away from parallel to the ground.
---@param customBallScaler pd_animator | nil --- 0 is straight forward, 90 is straight up, 180 is straight behind.
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) function Ball:launch(destX, destY, _, freshHit, _, power)
if freshHit then
self.flyBall = true
end
throwMeter:reset() throwMeter:reset()
self.heldBy = nil self.heldBy = nil
if not flyTimeMs then local flightDistance, x, y = utils.distanceBetween(self.x, self.y, destX, destY)
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower local timeToBounce = timeToFirstBounce(10)
end
self.flyTimeMs = flyTimeMs -- if not flyTimeMs then
-- flyTimeMs = flightDistance * C.DefaultLaunchPower
-- end
if customBallScaler then -- TODO? set a maxThrowDistance to limit throws by, instead
self.sizeAnimator = customBallScaler power = power or 5
else self.xVelocity = -1.1 * x -- (x / -flightDistance) * power
-- TODO? Scale based on distance? self.yVelocity = -1.1 * y -- (y / -flightDistance) * power
self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill) printTable({ x = x, y = y })
end printTable({
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc) destX = destX,
self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc) destY = destY,
if floaty then x = x,
self.floatAnimator:reset(flyTimeMs) y = y,
end -- xVelocity = self.xVelocity,
-- yVelocity = self.yVelocity,
-- timeToBounce = timeToBounce,
})
-- -- TODO? Scale based on distance?
-- self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
-- self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
-- self.zAnimator = self.animatorLib.new(flyTimeMs, flightDistance, C.GloveZ, utils.easingHill)
self.bounce = coroutine.create(bouncer(10))
end end

View File

@ -81,6 +81,7 @@ function Baserunning:outEligibleRunners(fielder)
for i, runner in pairs(self.runners) do for i, runner in pairs(self.runners) do
local runnerOnBase = utils.isTouchingBase(runner.x, runner.y) local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
-- TODO: Tag-outs when two baserunners are on the same base.
if -- Force out if -- Force out
touchedBase touchedBase
and runner.prevBase -- Make sure the runner is not standing at home and runner.prevBase -- Make sure the runner is not standing at home

View File

@ -67,7 +67,7 @@ C.ScoreboardDelayMs = 2000
--- generally as a check for whether or not it's in play. --- generally as a check for whether or not it's in play.
C.BallOffscreen = 999 C.BallOffscreen = 999
C.PitchAfterSeconds = 6 C.PitchAfterSeconds = 8
C.ReturnToPitcherAfterSeconds = 2.4 C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050 C.PitchFlyMs = 1050
C.PitchStartX = 195 C.PitchStartX = 195
@ -78,14 +78,14 @@ C.DefaultLaunchPower = 4
--- The max distance at which a fielder can tag out a runner. --- The max distance at which a fielder can tag out a runner.
C.TagDistance = 15 C.TagDistance = 15
C.BallCatchHitbox = 3 C.BallCatchHitbox = 15
--- The max distance at which a runner can be considered on base. --- The max distance at which a runner can be considered on base.
C.BaseHitbox = 10 C.BaseHitbox = 10
C.BattingPower = 25 C.BattingPower = 25
C.BatterHandPos = utils.xy(25, 15) C.BatterHandPos = utils.xy(25, 15)
C.GloveZ = 0 -- 10 C.GloveZ = 10
C.SmallestBallRadius = 6 C.SmallestBallRadius = 6

View File

@ -1,4 +1,3 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics local gfx = playdate.graphics
local ScoreFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft") local ScoreFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft")

View File

@ -1,4 +1,3 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics local gfx = playdate.graphics
---@alias SpriteCollection { smiling: pd_image, lowHat: pd_image, frowning: pd_image, back: pd_image } ---@alias SpriteCollection { smiling: pd_image, lowHat: pd_image, frowning: pd_image, back: pd_image }
@ -52,11 +51,9 @@ function buildCollection(base, back, logo, isDark)
} }
end end
--selene: allow(unscoped_variables)
---@type SpriteCollection ---@type SpriteCollection
AwayTeamSprites = nil AwayTeamSprites = nil
--selene: allow(unscoped_variables)
---@type SpriteCollection ---@type SpriteCollection
HomeTeamSprites = nil HomeTeamSprites = nil

View File

@ -1,15 +1,22 @@
--- @class Fielder { --- @class Glove
--- @field z number
--- @class Fielder
--- @field glove Glove
--- @field catchEligible boolean --- @field catchEligible boolean
--- @field name string
--- @field x number --- @field x number
--- @field y number --- @field y number
--- @field target XyPair | nil --- @field target XyPair | nil
--- @field speed number --- @field speed number
--- @field armStrength number
-- TODO: Run down baserunners in a pickle. -- TODO: Run down baserunners in a pickle.
---@class Fielding ---@class Fielding
---@field fielders table<string, Fielder> ---@field fielders table<string, Fielder>
---@field fielderHoldingBall Fielder | nil ---@field fielderHoldingBall Fielder | nil
---@field private onFlyOut fun()
Fielding = {} Fielding = {}
FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
@ -23,10 +30,14 @@ local function newFielder(name, speed)
name = name, name = name,
speed = speed * C.FielderRunMult, speed = speed * C.FielderRunMult,
catchEligible = true, catchEligible = true,
armStrength = 10,
glove = {
z = C.GloveZ
},
} }
end end
function Fielding.new() function Fielding.new(onFlyOut)
return setmetatable({ return setmetatable({
fielders = { fielders = {
first = newFielder("First", 40), first = newFielder("First", 40),
@ -39,6 +50,7 @@ function Fielding.new()
center = newFielder("Center", 50), center = newFielder("Center", 50),
right = newFielder("Right", 50), right = newFielder("Right", 50),
}, },
onFlyOut = onFlyOut,
---@type Fielder | nil ---@type Fielder | nil
fielderHoldingBall = nil, fielderHoldingBall = nil,
}, { __index = Fielding }) }, { __index = Fielding })
@ -78,26 +90,52 @@ end
---@param deltaSeconds number ---@param deltaSeconds number
---@param fielder Fielder ---@param fielder Fielder
---@param ballPos XyPair ---@param ballPos Point3d
---@return boolean inCatchingRange ---@return boolean inCatchingRange
local function updateFielderPosition(deltaSeconds, fielder, ballPos) local function updateFielderPosition(deltaSeconds, fielder, ballPos)
if fielder.target ~= nil then if fielder.target ~= nil then
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then -- if fielder.name == "Left" then
fielder.target = nil -- printTable({ target = fielder.target })
-- end
if fielder.target.z then
if not utils.moveAtSpeedZ(fielder, fielder.speed * deltaSeconds, fielder.target) then
if fielder.name == "Left" then
print("CLEAR LEFT'S 3D TARGET")
end
fielder.target = nil
end
else
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
if fielder.name == "Left" then
print("CLEAR LEFT'S 2D TARGET")
end
fielder.target = nil
end
end end
end end
return utils.distanceBetweenPoints(fielder, ballPos) < C.BallCatchHitbox --local distance = utils.distanceBetweenZ(fielder.x, fielder.y, C.GloveZ, ballPos.x, ballPos.y, ballPos.z)
if ballPos.z > C.GloveZ * 2 then
return false
end
local distance = utils.distanceBetween(fielder.x, fielder.y, ballPos.x, ballPos.y)
return distance < C.BallCatchHitbox
end 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 self table
---@param ballDestX number ---@param ballDestX number
---@param ballDestY number ---@param ballDestY number
function Fielding:haveSomeoneChase(ballDestX, ballDestY) ---@param ball Ball
function Fielding:haveSomeoneChase(ballDestX, ballDestY, ball)
local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY) local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY)
chasingFielder.target = { x = ballDestX, y = ballDestY } chasingFielder.target = ball
-- local timer = playdate.timer.new(1000)
-- timer.updateCallback = function()
-- printTable(chasingFielder.target)
-- end
print("chasingFielder: " .. chasingFielder.name)
printTable(ball)
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)
@ -112,13 +150,24 @@ end
---@param deltaSeconds number ---@param deltaSeconds number
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the 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 = nil local fielderHoldingBall
for _, fielder in pairs(self.fielders) do for _, fielder in pairs(self.fielders) do
local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball) local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball)
-- if inCatchingRange then
-- printTable({
-- inCatchingRange = inCatchingRange,
-- catchEligible = fielder.catchEligible,
-- fielderName = fielder.name,
-- })
-- end
if inCatchingRange and fielder.catchEligible then if inCatchingRange and fielder.catchEligible then
-- TODO: Base this catch on fielder skill? -- TODO: Base this catch on fielder skill?
fielderHoldingBall = fielder fielderHoldingBall = fielder
ball.heldBy = fielder -- How much havoc will this wreak? if ball.flyBall then
self.onFlyOut()
ball.flyBall = false
end
ball:caughtBy(fielder)
end end
end end
-- TODO: The need is growing for a distinction between touching the ball and holding the ball. -- TODO: The need is growing for a distinction between touching the ball and holding the ball.
@ -153,21 +202,17 @@ end
---@param ball { launch: LaunchBall } ---@param ball { launch: LaunchBall }
---@param throwFlyMs number ---@param throwFlyMs number
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs) local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
while true do while field.fielderHoldingBall == nil do
if field.fielderHoldingBall == nil then coroutine.yield()
coroutine.yield()
else
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end)
closestFielder.target = targetBase
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return
end
end end
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
end)
closestFielder.target = targetBase
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, false, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
end end
--- Buffer in a fielder throw action. --- Buffer in a fielder throw action.
@ -182,7 +227,7 @@ function Fielding:userThrowTo(targetBase, ball, throwFlyMs)
end) end)
end end
function Fielding:celebrate() function Fielding.celebrate()
FielderDanceAnimator:reset(C.DanceBounceMs) FielderDanceAnimator:reset(C.DanceBounceMs)
end end

View File

@ -7,7 +7,7 @@ function getDrawOffset(ballX, ballY)
if ballY > C.Screen.H or ballX >= C.BallOffscreen then if ballY > C.Screen.H or ballX >= C.BallOffscreen then
return 0, 0 return 0, 0
end end
offsetY = math.max(0, -1 * ballY) offsetY = math.max(0, -1.4 * ballY)
if ballX > 0 and ballX < C.Screen.W then if ballX > 0 and ballX < C.Screen.W then
offsetX = 0 offsetX = 0
@ -17,7 +17,7 @@ function getDrawOffset(ballX, ballY)
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W) offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
end end
return offsetX * 1.3, offsetY * 1.5 return offsetX * 1.7, offsetY
end end
---@class Blipper ---@class Blipper

View File

@ -85,6 +85,7 @@ function MainMenu.update()
if playdate.buttonJustPressed(playdate.kButtonA) then if playdate.buttonJustPressed(playdate.kButtonA) then
startGame() startGame()
end end
startGame()
if playdate.buttonJustPressed(playdate.kButtonUp) then if playdate.buttonJustPressed(playdate.kButtonUp) then
inningCountSelection = inningCountSelection + 1 inningCountSelection = inningCountSelection + 1
end end

View File

@ -15,9 +15,8 @@ import 'CoreLibs/ui.lua'
--- destX: number, --- destX: number,
--- destY: number, --- destY: number,
--- easingFunc: EasingFunc, --- easingFunc: EasingFunc,
--- freshHit: boolean | nil,
--- flyTimeMs: number | nil, --- flyTimeMs: number | nil,
--- floaty: boolean | nil,
--- customBallScaler: pd_animator | nil,
--- ) --- )
-- stylua: ignore start -- stylua: ignore start
@ -107,8 +106,7 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
teams.away.score = 0 teams.away.score = 0
teams.home.score = 0 teams.home.score = 0
announcer = announcer or Announcer.new() announcer = announcer or Announcer.new()
fielding = fielding or Fielding.new() settings.userTeam = "home" -- "away"
settings.userTeam = nil -- "away"
local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat) local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat) local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat)
@ -119,7 +117,6 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
local o = setmetatable({ local o = setmetatable({
settings = settings, settings = settings,
announcer = announcer, announcer = announcer,
fielding = fielding,
homeTeamBlipper = homeTeamBlipper, homeTeamBlipper = homeTeamBlipper,
awayTeamBlipper = awayTeamBlipper, awayTeamBlipper = awayTeamBlipper,
state = state or { state = state or {
@ -131,7 +128,7 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
battingTeam = battingTeam, battingTeam = battingTeam,
offenseState = C.Offense.batting, offenseState = C.Offense.batting,
inning = 1, inning = 1,
catcherThrownBall = false, catcherThrownBall = true,
secondsSinceLastRunnerMove = 0, secondsSinceLastRunnerMove = 0,
secondsSincePitchAllowed = 0, secondsSincePitchAllowed = 0,
battingTeamSprites = settings.awayTeamSprites, battingTeamSprites = settings.awayTeamSprites,
@ -140,14 +137,18 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
}, },
}, { __index = Game }) }, { __index = Game })
o.fielding = fielding or Fielding.new(function()
print("Fly out!")
end)
o.baserunning = baserunning or Baserunning.new(announcer, function() o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning() o:nextHalfInning()
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)
playdate.timer.new(2000, function() playdate.timer.new(3500, function()
ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false) print("Start pitcher with ball")
ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, nil, 6)
end) end)
BootTune:play() BootTune:play()
@ -238,28 +239,29 @@ end
---@param pitchTypeIndex number | nil ---@param pitchTypeIndex number | nil
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex) function Game:pitch(pitchFlyTimeMs, pitchTypeIndex)
Fielding.markIneligible(self.fielding.fielders.pitcher) Fielding.markIneligible(self.fielding.fielders.pitcher)
self.state.ball:launch(C.PitchStartX, C.PitchEndY, nil, false, nil, 2000 / pitchFlyTimeMs)
self.state.ball.heldBy = nil self.state.ball.heldBy = nil
self.state.catcherThrownBall = false self.state.catcherThrownBall = false
self.state.offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex](self.state.ball) -- local current = Pitches[pitchTypeIndex](self.state.ball)
self.state.ball.xAnimator = current.x -- self.state.ball.xAnimator = current.x
self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y -- self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y
-- TODO: This would need to be sanely replaced in ball:launch() etc. -- -- TODO: This would need to be sanely replaced in ball:launch() etc.
-- if current.z then -- -- if current.z then
-- ball.floatAnimator = current.z -- -- ball.zAnimator = current.z
-- ball.floatAnimator:reset() -- -- ball.zAnimator:reset()
-- -- end
-- if pitchFlyTimeMs then
-- self.state.ball.xAnimator:reset(pitchFlyTimeMs)
-- self.state.ball.yAnimator:reset(pitchFlyTimeMs)
-- else
-- self.state.ball.xAnimator:reset()
-- self.state.ball.yAnimator:reset()
-- end -- end
if pitchFlyTimeMs then
self.state.ball.xAnimator:reset(pitchFlyTimeMs)
self.state.ball.yAnimator:reset(pitchFlyTimeMs)
else
self.state.ball.xAnimator:reset()
self.state.ball.yAnimator:reset()
end
self.state.secondsSincePitchAllowed = 0 self.state.secondsSincePitchAllowed = 0
end end
@ -267,7 +269,7 @@ function Game:nextHalfInning()
pitchTracker:reset() pitchTracker:reset()
local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score
if not gameOver then if not gameOver then
self.fielding:celebrate() Fielding.celebrate()
self.state.secondsSinceLastRunnerMove = -7 self.state.secondsSinceLastRunnerMove = -7
self.fielding:benchTo(self:getFieldingTeam().benchPosition) self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...") self.announcer:say("SWITCHING SIDES...")
@ -379,20 +381,20 @@ function Game:updateBatting(batDeg, batSpeed)
-- Hit! -- Hit!
BatCrackReverb:play() BatCrackReverb:play()
self.state.offenseState = C.Offense.running self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90) -- local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15) -- local mult = math.abs(batSpeed / 15)
local ballVelX = mult * 10 * math.sin(ballAngle) -- local ballVelX = mult * 10 * math.sin(ballAngle)
local ballVelY = mult * 5 * math.cos(ballAngle) -- local ballVelY = mult * 5 * math.cos(ballAngle)
if ballVelY > 0 then -- if ballVelY > 0 then
ballVelX = ballVelX * -1 -- ballVelX = ballVelX * -1
ballVelY = ballVelY * -1 -- ballVelY = ballVelY * -1
end -- end
local ballDestX = self.state.ball.x + (ballVelX * C.BattingPower) local ballDestX = self.fielding.fielders.left.x -- self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = self.state.ball.y + (ballVelY * C.BattingPower) local ballDestY = self.fielding.fielders.left.y -- self.state.ball.y + (ballVelY * C.BattingPower)
pitchTracker:reset() pitchTracker:reset()
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) print("Hit ball!")
self.state.ball:launch(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler) self.state.ball:launch(ballDestX, ballDestY, playdate.easingFunctions.outQuint, true, 3000, batSpeed / 3)
-- 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.
if utils.isFoulBall(ballDestX, ballDestY) then if utils.isFoulBall(ballDestX, ballDestY) then
@ -404,7 +406,7 @@ function Game:updateBatting(batDeg, batSpeed)
self.baserunning:convertBatterToRunner() self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ballDestX, ballDestY) self.fielding:haveSomeoneChase(ballDestX, ballDestY, self.state.ball)
end end
end end
@ -443,7 +445,7 @@ function Game:updateGameState()
crankLimited = crankLimited * -1 crankLimited = crankLimited * -1
end end
self.state.ball:updatePosition() self.state.ball:updatePosition(self.state.deltaSeconds)
local userOnOffense, userOnDefense = self:userIsOn("offense") local userOnOffense, userOnDefense = self:userIsOn("offense")
@ -471,7 +473,9 @@ function Game:updateGameState()
self:walk() self:walk()
end end
-- Catcher has the ball. Throw it back to the pitcher -- Catcher has the ball. Throw it back to the pitcher
self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) print("Catcher return ball to pitcher")
self.state.ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, 20)
self.fielding:markAllIneligible()
self.state.catcherThrownBall = true self.state.catcherThrownBall = true
end end
@ -512,8 +516,10 @@ function Game:updateGameState()
self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds
if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher -- End of play. Throw the ball back to the pitcher
self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) print("Return ball to pitcher")
self.fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly. self.state.ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, 20)
-- This is ugly. Maybe Fielding should handle the return throw directly.
self.fielding:markAllIneligible()
self.fielding:resetFielderPositions() self.fielding:resetFielderPositions()
self.state.offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter() -- TODO: Remove, or replace with nextBatter()
@ -558,7 +564,8 @@ function Game:update()
gfx.clear() gfx.clear()
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
local offsetX, offsetY = getDrawOffset(ball.x, ball.y) local ballY = ball.y - (ball.z * 0.2)
local offsetX, offsetY = getDrawOffset(ball.x, ballY)
gfx.setDrawOffset(offsetX, offsetY) gfx.setDrawOffset(offsetX, offsetY)
GrassBackground:draw(-400, -240) GrassBackground:draw(-400, -240)
@ -619,10 +626,10 @@ function Game:update()
gfx.setLineWidth(2) gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite) gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(ball.x, ball.y, ball.size) gfx.fillCircleAtPoint(ball.x, ballY, ball.size)
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(ball.x, ball.y, ball.size) gfx.drawCircleAtPoint(ball.x, ballY, ball.size)
end end
gfx.setDrawOffset(0, 0) gfx.setDrawOffset(0, 0)
@ -634,6 +641,7 @@ function Game:update()
self.announcer:draw(C.Center.x, 10) self.announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then if playdate.isCrankDocked() then
-- luacheck: ignore
playdate.ui.crankIndicator:draw() playdate.ui.crankIndicator:draw()
end end
end end
@ -644,3 +652,19 @@ playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
MainMenu.start(Game) MainMenu.start(Game)
-- local b = coroutine.create(bouncer(10))
-- local x = 0
--
-- function playdate.update()
-- -- print(playdate.getFPS())
-- local deltaSeconds = playdate.getElapsedTime() or 0
-- playdate.resetElapsedTime()
-- local alive, z = coroutine.resume(b, deltaSeconds)
-- if alive then
-- z = z * 10
-- x = x + (deltaSeconds * 40)
-- gfx.setColor(gfx.kColorBlack)
-- gfx.drawCircleAtPoint(x, 240 - z, 5)
-- end
-- end

View File

@ -5,7 +5,6 @@ local npcBatSpeed = 1500
---@class Npc ---@class Npc
---@field runners Runner[] ---@field runners Runner[]
---@field fielders Fielder[] ---@field fielders Fielder[]
-- selene: allow(unscoped_variables)
Npc = {} Npc = {}
---@param runners Runner[] ---@param runners Runner[]
@ -22,7 +21,7 @@ end
---@param catcherThrownBall boolean ---@param catcherThrownBall boolean
---@param deltaSec number ---@param deltaSec number
---@return number ---@return number
function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec) function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec) -- luacheck: no unused args
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed) npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
else else
@ -32,7 +31,7 @@ function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec)
return npcBatDeg return npcBatDeg
end end
function Npc:batSpeed() function Npc:batSpeed() -- luacheck: no unused args
return npcBatSpeed / 1.5 return npcBatSpeed / 1.5
end end
@ -122,6 +121,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)
printTable({ targetX = targetX, targetY = targetY })
if targetX ~= nil and targetY ~= nil then if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate) local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
return grabCandidate.catchEligible return grabCandidate.catchEligible
@ -130,8 +130,11 @@ local function tryToMakeAPlay(fielders, fielder, runners, ball)
if nearestFielder == fielder then if nearestFielder == fielder then
ball.heldBy = fielder ball.heldBy = fielder
else else
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true) playdate.timer.new(500, function()
Fielding.markIneligible(nearestFielder) print("Try to make a play")
ball:launch(targetX, targetY, playdate.easingFunctions.linear, false, nearestFielder.armStrength)
Fielding.markIneligible(nearestFielder)
end)
end end
end end
end end
@ -151,7 +154,7 @@ function Npc:fielderAction(fielder, outedSomeRunner, ball)
end end
---@return number ---@return number
function Npc:pitchSpeed() function Npc:pitchSpeed() -- luacheck: no unused args
return 2 return 2
end end

View File

@ -24,6 +24,9 @@ function fakeBall(x, y, z)
y = y, y = y,
z = z or 0, z = z or 0,
heldBy = nil, heldBy = nil,
caughtBy = function(self, fielder)
self.heldBy = fielder
end,
} }
end end
@ -40,6 +43,8 @@ function testBallPickedUpByNearbyFielders()
local secondBaseman = fielding.fielders.second local secondBaseman = fielding.fielders.second
ball.x = secondBaseman.x ball.x = secondBaseman.x
ball.y = secondBaseman.y ball.y = secondBaseman.y
ball.z = C.GloveZ
fielding:updateFielderPositions(ball, 0.01) fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIs(secondBaseman, ball.heldBy, "Ball should be held by the nearest fielder") luaunit.assertIs(secondBaseman, ball.heldBy, "Ball should be held by the nearest fielder")
end end

View File

@ -64,6 +64,16 @@ function utils.normalizeVector(x1, y1, x2, y2)
local distance, x, y = utils.distanceBetween(x1, y1, x2, y2) local distance, x, y = utils.distanceBetween(x1, y1, x2, y2)
return x / distance, y / distance, distance return x / distance, y / distance, distance
end end
--- Returns the normalized vector as two values, plus the distance between the given points.
---@param x1 number
---@param y1 number
---@param x2 number
---@param y2 number
---@return number x, number y, number z, number distance
function utils.normalize3dVector(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
--- 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.
@ -83,6 +93,25 @@ function utils.moveAtSpeed(mover, speed, target)
return false return false
end end
--- Same as moveAtSpeed(), but takes into account any Z-distance.
--- Does NOT shift the z of the given mover.
---@param mover { x: number, y: number, z: number }
---@param speed number
---@param target { x: number, y: number, z: number }
---@return boolean
function utils.moveAtSpeedZ(mover, speed, target)
local z = mover.z or C.GloveZ
local x, y, _, distance = utils.normalize3dVector(mover.x, mover.y, z, target.x, target.y, target.z)
if distance > 1 then
mover.x = mover.x - (x * speed)
mover.y = mover.y - (y * speed)
return true
end
return false
end
---@generic T ---@generic T
---@param array T[] ---@param array T[]
---@param condition fun(T): boolean ---@param condition fun(T): boolean