Committing my "realer" physics changes as they stand

Which is to say, *very, very, unplayable*
This commit is contained in:
Sage Vaillancourt 2025-02-16 18:34:45 -05:00
parent db1409d94d
commit 3e256d95c9
12 changed files with 362 additions and 121 deletions

View File

@ -1,14 +1,18 @@
---@class Ball
---@field x number
---@field y number
---@field xVelocity number
---@field yVelocity number
---@field z number
---@field flyBall boolean
---@field size number
---@field heldBy Fielder | nil
---@field xAnimator SimpleAnimator
---@field yAnimator SimpleAnimator
---@field sizeAnimator SimpleAnimator
---@field floatAnimator SimpleAnimator
---@field zAnimator SimpleAnimator
---@field private animatorLib pd_animator_lib
---@field private bounce thread Requires deltaSeconds on resume, returns ball height.
---@field private flyTimeMs number
Ball = {}
@ -17,9 +21,10 @@ Ball = {}
function Ball.new(animatorLib)
return setmetatable({
animatorLib = animatorLib,
x = C.Center.x --[[@as number]],
y = C.Center.y --[[@as number]],
x = 400 --[[@as number]],
y = 300 --[[@as number]],
z = 0,
flyBall = false,
size = C.SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]],
@ -29,52 +34,184 @@ function Ball.new(animatorLib)
-- TODO? Replace these with a ballAnimatorZ?
-- ...that might lose some of the magic of both. Compromise available? idk
sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
floatAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
zAnimator = animatorLib.new(2000, -60, 0, utils.easingHill),
}, { __index = Ball })
end
function Ball:updatePosition()
if self.heldBy then
self.x = self.heldBy.x
self.y = self.heldBy.y
self.z = C.GloveZ
self.size = C.SmallestBallRadius
else
self.x = self.xAnimator:currentValue()
local z = self.floatAnimator:currentValue()
-- TODO: This `+ z` is more graphics logic than physics logic
self.y = self.yAnimator:currentValue() + z
self.z = z
self.size = self.sizeAnimator:currentValue()
---@param fielder Fielder
function Ball:caughtBy(fielder)
self.heldBy = fielder
-- if self.flyBall then
-- TODO: Signal an out if the ball hasn't touched the ground.
-- end
end
function timeToFirstBounce(v)
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.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
---@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.
---@param destX number
---@param destY number
---@param easingFunc EasingFunc
---@param flyTimeMs number | nil
---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
---@param freshHit boolean | nil
---@param flyTimeMs number | nil The angle away from parallel to the ground.
--- 0 is straight forward, 90 is straight up, 180 is straight behind.
function Ball:launch(destX, destY, _, freshHit, _, power)
if freshHit then
self.flyBall = true
end
throwMeter:reset()
self.heldBy = nil
if not flyTimeMs then
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
end
local flightDistance, x, y = utils.distanceBetween(self.x, self.y, destX, destY)
local timeToBounce = timeToFirstBounce(10)
self.flyTimeMs = flyTimeMs
-- if not flyTimeMs then
-- flyTimeMs = flightDistance * C.DefaultLaunchPower
-- end
if customBallScaler then
self.sizeAnimator = customBallScaler
else
-- TODO? Scale based on distance?
self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
end
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc)
if floaty then
self.floatAnimator:reset(flyTimeMs)
end
-- TODO? set a maxThrowDistance to limit throws by, instead
power = power or 5
self.xVelocity = -1.1 * x -- (x / -flightDistance) * power
self.yVelocity = -1.1 * y -- (y / -flightDistance) * power
printTable({ x = x, y = y })
printTable({
destX = destX,
destY = destY,
x = x,
y = y,
-- 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

View File

@ -81,6 +81,7 @@ function Baserunning:outEligibleRunners(fielder)
for i, runner in pairs(self.runners) do
local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
-- TODO: Tag-outs when two baserunners are on the same base.
if -- Force out
touchedBase
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.
C.BallOffscreen = 999
C.PitchAfterSeconds = 6
C.PitchAfterSeconds = 8
C.ReturnToPitcherAfterSeconds = 2.4
C.PitchFlyMs = 1050
C.PitchStartX = 195
@ -78,14 +78,14 @@ C.DefaultLaunchPower = 4
--- The max distance at which a fielder can tag out a runner.
C.TagDistance = 15
C.BallCatchHitbox = 3
C.BallCatchHitbox = 15
--- The max distance at which a runner can be considered on base.
C.BaseHitbox = 10
C.BattingPower = 25
C.BatterHandPos = utils.xy(25, 15)
C.GloveZ = 0 -- 10
C.GloveZ = 10
C.SmallestBallRadius = 6

View File

@ -1,4 +1,3 @@
-- selene: allow(shadowing)
local gfx = playdate.graphics
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
---@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
--selene: allow(unscoped_variables)
---@type SpriteCollection
AwayTeamSprites = nil
--selene: allow(unscoped_variables)
---@type SpriteCollection
HomeTeamSprites = nil

View File

@ -1,15 +1,22 @@
--- @class Fielder {
--- @class Glove
--- @field z number
--- @class Fielder
--- @field glove Glove
--- @field catchEligible boolean
--- @field name string
--- @field x number
--- @field y number
--- @field target XyPair | nil
--- @field speed number
--- @field armStrength number
-- TODO: Run down baserunners in a pickle.
---@class Fielding
---@field fielders table<string, Fielder>
---@field fielderHoldingBall Fielder | nil
---@field private onFlyOut fun()
Fielding = {}
FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
@ -23,10 +30,14 @@ local function newFielder(name, speed)
name = name,
speed = speed * C.FielderRunMult,
catchEligible = true,
armStrength = 10,
glove = {
z = C.GloveZ
},
}
end
function Fielding.new()
function Fielding.new(onFlyOut)
return setmetatable({
fielders = {
first = newFielder("First", 40),
@ -39,6 +50,7 @@ function Fielding.new()
center = newFielder("Center", 50),
right = newFielder("Right", 50),
},
onFlyOut = onFlyOut,
---@type Fielder | nil
fielderHoldingBall = nil,
}, { __index = Fielding })
@ -78,26 +90,52 @@ end
---@param deltaSeconds number
---@param fielder Fielder
---@param ballPos XyPair
---@param ballPos Point3d
---@return boolean inCatchingRange
local function updateFielderPosition(deltaSeconds, fielder, ballPos)
if fielder.target ~= nil then
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
fielder.target = nil
-- if fielder.name == "Left" then
-- 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
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
--- Selects the nearest fielder to move toward the given coordinates.
--- Other fielders should attempt to cover their bases
---@param self table
---@param ballDestX 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)
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
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
@ -112,13 +150,24 @@ end
---@param deltaSeconds number
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
function Fielding:updateFielderPositions(ball, deltaSeconds)
local fielderHoldingBall = nil
local fielderHoldingBall
for _, fielder in pairs(self.fielders) do
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
-- TODO: Base this catch on fielder skill?
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
-- 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 throwFlyMs number
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
while true do
if field.fielderHoldingBall == nil then
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
while field.fielderHoldingBall == nil do
coroutine.yield()
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
--- Buffer in a fielder throw action.
@ -182,7 +227,7 @@ function Fielding:userThrowTo(targetBase, ball, throwFlyMs)
end)
end
function Fielding:celebrate()
function Fielding.celebrate()
FielderDanceAnimator:reset(C.DanceBounceMs)
end

View File

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

View File

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

View File

@ -15,9 +15,8 @@ import 'CoreLibs/ui.lua'
--- destX: number,
--- destY: number,
--- easingFunc: EasingFunc,
--- freshHit: boolean | nil,
--- flyTimeMs: number | nil,
--- floaty: boolean | nil,
--- customBallScaler: pd_animator | nil,
--- )
-- stylua: ignore start
@ -107,8 +106,7 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
teams.away.score = 0
teams.home.score = 0
announcer = announcer or Announcer.new()
fielding = fielding or Fielding.new()
settings.userTeam = nil -- "away"
settings.userTeam = "home" -- "away"
local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.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({
settings = settings,
announcer = announcer,
fielding = fielding,
homeTeamBlipper = homeTeamBlipper,
awayTeamBlipper = awayTeamBlipper,
state = state or {
@ -131,7 +128,7 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
battingTeam = battingTeam,
offenseState = C.Offense.batting,
inning = 1,
catcherThrownBall = false,
catcherThrownBall = true,
secondsSinceLastRunnerMove = 0,
secondsSincePitchAllowed = 0,
battingTeamSprites = settings.awayTeamSprites,
@ -140,14 +137,18 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
},
}, { __index = Game })
o.fielding = fielding or Fielding.new(function()
print("Fly out!")
end)
o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning()
end)
o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)
o.fielding:resetFielderPositions(teams.home.benchPosition)
playdate.timer.new(2000, function()
ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
playdate.timer.new(3500, function()
print("Start pitcher with ball")
ball:launch(C.PitcherStartPos.x, C.PitcherStartPos.y, playdate.easingFunctions.linear, false, nil, 6)
end)
BootTune:play()
@ -238,28 +239,29 @@ end
---@param pitchTypeIndex number | nil
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex)
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.catcherThrownBall = false
self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex](self.state.ball)
self.state.ball.xAnimator = current.x
self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y
-- local current = Pitches[pitchTypeIndex](self.state.ball)
-- self.state.ball.xAnimator = current.x
-- 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.
-- if current.z then
-- ball.floatAnimator = current.z
-- ball.floatAnimator:reset()
-- -- TODO: This would need to be sanely replaced in ball:launch() etc.
-- -- if current.z then
-- -- ball.zAnimator = current.z
-- -- 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
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
end
@ -267,7 +269,7 @@ function Game:nextHalfInning()
pitchTracker:reset()
local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score
if not gameOver then
self.fielding:celebrate()
Fielding.celebrate()
self.state.secondsSinceLastRunnerMove = -7
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...")
@ -379,20 +381,20 @@ function Game:updateBatting(batDeg, batSpeed)
-- Hit!
BatCrackReverb:play()
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 ballVelX = mult * 10 * math.sin(ballAngle)
local ballVelY = mult * 5 * math.cos(ballAngle)
if ballVelY > 0 then
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
local ballDestX = self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = self.state.ball.y + (ballVelY * C.BattingPower)
-- local mult = math.abs(batSpeed / 15)
-- local ballVelX = mult * 10 * math.sin(ballAngle)
-- local ballVelY = mult * 5 * math.cos(ballAngle)
-- if ballVelY > 0 then
-- ballVelX = ballVelX * -1
-- ballVelY = ballVelY * -1
-- end
local ballDestX = self.fielding.fielders.left.x -- self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = self.fielding.fielders.left.y -- self.state.ball.y + (ballVelY * C.BattingPower)
pitchTracker:reset()
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
self.state.ball:launch(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler)
print("Hit ball!")
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.
if utils.isFoulBall(ballDestX, ballDestY) then
@ -404,7 +406,7 @@ function Game:updateBatting(batDeg, batSpeed)
self.baserunning:convertBatterToRunner()
self.fielding:haveSomeoneChase(ballDestX, ballDestY)
self.fielding:haveSomeoneChase(ballDestX, ballDestY, self.state.ball)
end
end
@ -443,7 +445,7 @@ function Game:updateGameState()
crankLimited = crankLimited * -1
end
self.state.ball:updatePosition()
self.state.ball:updatePosition(self.state.deltaSeconds)
local userOnOffense, userOnDefense = self:userIsOn("offense")
@ -471,7 +473,9 @@ function Game:updateGameState()
self:walk()
end
-- 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
end
@ -512,8 +516,10 @@ function Game:updateGameState()
self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds
if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher
self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
self.fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
print("Return ball to pitcher")
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.state.offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter()
@ -558,7 +564,8 @@ function Game:update()
gfx.clear()
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)
GrassBackground:draw(-400, -240)
@ -619,10 +626,10 @@ function Game:update()
gfx.setLineWidth(2)
gfx.setColor(gfx.kColorWhite)
gfx.fillCircleAtPoint(ball.x, ball.y, ball.size)
gfx.fillCircleAtPoint(ball.x, ballY, ball.size)
gfx.setColor(gfx.kColorBlack)
gfx.drawCircleAtPoint(ball.x, ball.y, ball.size)
gfx.drawCircleAtPoint(ball.x, ballY, ball.size)
end
gfx.setDrawOffset(0, 0)
@ -634,6 +641,7 @@ function Game:update()
self.announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then
-- luacheck: ignore
playdate.ui.crankIndicator:draw()
end
end
@ -644,3 +652,19 @@ playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
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
---@field runners Runner[]
---@field fielders Fielder[]
-- selene: allow(unscoped_variables)
Npc = {}
---@param runners Runner[]
@ -22,7 +21,7 @@ end
---@param catcherThrownBall boolean
---@param deltaSec 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
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
else
@ -32,7 +31,7 @@ function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec)
return npcBatDeg
end
function Npc:batSpeed()
function Npc:batSpeed() -- luacheck: no unused args
return npcBatSpeed / 1.5
end
@ -122,6 +121,7 @@ end
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners)
printTable({ targetX = targetX, targetY = targetY })
if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
return grabCandidate.catchEligible
@ -130,8 +130,11 @@ local function tryToMakeAPlay(fielders, fielder, runners, ball)
if nearestFielder == fielder then
ball.heldBy = fielder
else
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
Fielding.markIneligible(nearestFielder)
playdate.timer.new(500, function()
print("Try to make a play")
ball:launch(targetX, targetY, playdate.easingFunctions.linear, false, nearestFielder.armStrength)
Fielding.markIneligible(nearestFielder)
end)
end
end
end
@ -151,7 +154,7 @@ function Npc:fielderAction(fielder, outedSomeRunner, ball)
end
---@return number
function Npc:pitchSpeed()
function Npc:pitchSpeed() -- luacheck: no unused args
return 2
end

View File

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

View File

@ -64,6 +64,16 @@ function utils.normalizeVector(x1, y1, x2, y2)
local distance, x, y = utils.distanceBetween(x1, y1, x2, y2)
return x / distance, y / distance, distance
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.
--- 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
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
---@param array T[]
---@param condition fun(T): boolean