diff --git a/src/ball.lua b/src/ball.lua index 5bfd234..9f7e6f8 100644 --- a/src/ball.lua +++ b/src/ball.lua @@ -5,6 +5,7 @@ ---@field size number ---@field heldBy Fielder | nil ---@field catchable boolean +---@field isFlyBall boolean ---@field xAnimator SimpleAnimator ---@field yAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator @@ -12,6 +13,10 @@ ---@field private animatorLib pd_animator_lib Ball = {} +local function defaultFloatAnimator(animatorLib) + return animatorLib.new(2000, -60, 0, utils.easingHill) +end + ---@param animatorLib pd_animator_lib ---@return Ball function Ball.new(animatorLib) @@ -30,15 +35,14 @@ 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), + floatAnimator = defaultFloatAnimator(animatorLib), }, { __index = Ball }) end -function Ball:updatePosition() +---@param deltaSeconds number +function Ball:updatePosition(deltaSeconds) if self.heldBy then - self.x = self.heldBy.x - self.y = self.heldBy.y - self.z = C.GloveZ + utils.moveAtSpeedZ(self, 100 * deltaSeconds, { x = self.heldBy.x, y = self.heldBy.y, z = C.GloveZ }) self.size = C.SmallestBallRadius else self.x = self.xAnimator:currentValue() @@ -46,7 +50,11 @@ function Ball:updatePosition() -- TODO: This `+ z` is more graphics logic than physics logic self.y = self.yAnimator:currentValue() + 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 @@ -63,9 +71,11 @@ end ---@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 customFloater pd_animator | nil +---@param isHit boolean +function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customFloater, isHit) self.heldBy = nil + self.isFlyBall = isHit -- Prevent silly insta-catches 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 end - if customBallScaler then - self.sizeAnimator = customBallScaler + if customFloater then + self.floatAnimator = customFloater 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 self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc) self.xAnimator = self.animatorLib.new(flyTimeMs, self.x, destX, easingFunc) diff --git a/src/baserunning.lua b/src/baserunning.lua index fa904fc..3d92567 100644 --- a/src/baserunning.lua +++ b/src/baserunning.lua @@ -176,6 +176,10 @@ function Baserunning:pushNewBatter() return new end +function Baserunning:getNewestRunner() + return self.runners[#self.runners] +end + ---@param runnerIndex number function Baserunning:runnerScored(runnerIndex) self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex] diff --git a/src/constants.lua b/src/constants.lua index d8ee848..8827d32 100644 --- a/src/constants.lua +++ b/src/constants.lua @@ -99,6 +99,7 @@ C.Offense = { running = "running", walking = "walking", homeRun = "homeRun", + fliedOut = "running", } ---@alias Side "offense" | "defense" diff --git a/src/fielding.lua b/src/fielding.lua index 234dec4..b5af496 100644 --- a/src/fielding.lua +++ b/src/fielding.lua @@ -67,7 +67,8 @@ end --- 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. -function Fielding:resetFielderPositions(fromOffTheField) +---@param immediate boolean | nil +function Fielding:resetFielderPositions(fromOffTheField, immediate) if fromOffTheField then for _, fielder in pairs(self.fielders) do 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.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) } + + 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 ---@param deltaSeconds number @@ -96,7 +104,7 @@ local function updateFielderPosition(deltaSeconds, fielder, ball) local currentTarget = fielder.targets[#fielder.targets] local willMove = utils.moveAtSpeed(nextFielderPos, fielder.speed * deltaSeconds, currentTarget) - if willMove and utils.pointIsAboveLine(nextFielderPos, C.BottomOfOutfieldWall) then + if willMove and utils.pointIsAboveLine(nextFielderPos, C.BottomOfOutfieldWall, 40) then local targetCount = #fielder.targets -- Back up a little 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. --- Other fielders should attempt to cover their bases ----@param ballDestX number ----@param ballDestY number -function Fielding:haveSomeoneChase(ballDestX, ballDestY) - local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY) - chasingFielder.targets = { utils.xy(ballDestX, ballDestY) } +---@param ball Point3d +---@param ballDest XyPair +function Fielding:haveSomeoneChase(ball, ballDest) + local chasingFielder = utils.getNearestOf(self.fielders, ballDest.x, ballDest.y) + -- Start moving toward the ball directly after reaching ballDest + chasingFielder.targets = { ball, ballDest } for _, base in ipairs(C.Bases) do local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder) @@ -146,19 +155,24 @@ end --- **Also updates `ball.heldby`** ---@param ball Ball ---@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) local fielderHoldingBall + local caughtAFlyBall = false for _, fielder in pairs(self.fielders) do -- TODO: Base this catch on fielder skill? local canCatch = updateFielderPosition(deltaSeconds, fielder, ball) if canCatch then fielderHoldingBall = fielder ball.heldBy = fielder -- How much havoc will this wreak? + if ball.isFlyBall then + ball.isFlyBall = false + caughtAFlyBall = true + end end end self.fielderHoldingBall = fielderHoldingBall - return fielderHoldingBall + return fielderHoldingBall, caughtAFlyBall end -- TODO? Start moving target fielders close sooner? diff --git a/src/main.lua b/src/main.lua index ad36301..f71c7ef 100644 --- a/src/main.lua +++ b/src/main.lua @@ -64,7 +64,9 @@ import 'draw/transitions.lua' -- TODO: Customizable field structure. E.g. stands and ads etc. ---@type pd_graphics_lib -local gfx , C = playdate.graphics, C +local gfx = playdate.graphics + +local C = C ---@alias Team { benchPosition: XyPair } ---@type table @@ -153,8 +155,8 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state) end) o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders) - o.fielding:resetFielderPositions(teams.home.benchPosition) - playdate.timer.new(2000, function() + o.fielding:resetFielderPositions(teams.home.benchPosition, settings.userTeam == nil) + playdate.timer.new(settings.userTeam == nil and 10 or 2000, function() o:returnToPitcher() end) o.characters = Characters.new(settings.homeTeamSpriteGroup, settings.awayTeamSpriteGroup) @@ -393,12 +395,13 @@ function Game:updateBatting(offenseHandler) -- Hit! -- TODO: animate bat-flip or something + local isFlyBall = math.random() > 0.5 self:saveToFile() BatCrackReverb:play() self.state.offenseState = C.Offense.running pitchTracker:reset() - local flyTimeMs = 2000 + local flyTimeMs = 8000 -- TODO? A dramatic eye-level view on a home-run could be sick. local battingTeamStats = self:battingTeamCurrentInning() battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest @@ -410,28 +413,38 @@ function Game:updateBatting(offenseHandler) return end - local isHomeRun = utils.pointIsAboveLine(ballDest, C.OutfieldWall) - if isHomeRun then - playdate.timer.new(flyTimeMs, function() - -- 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() + local isPastOutfieldWall, nearbyPointAbove = utils.pointIsAboveLine(ballDest, C.OutfieldWall) + + if isPastOutfieldWall then + if not isFlyBall then + -- Grounder at the wall! + ballDest.y = nearbyPointAbove.y - 8 + else + -- Home run! + playdate.timer.new(flyTimeMs, function() + -- 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 - local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) - ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler) + local ballHeightAnimator = isFlyBall + 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.fielding:haveSomeoneChase(ballDest.x, ballDest.y) + self.fielding:haveSomeoneChase(ball, ballDest) end ---@param appliedSpeed number | fun(runner: Runner): number @@ -498,7 +511,15 @@ function Game:updateGameState() playdate.resetElapsedTime() self.state.ball:updatePosition() - local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) + local fielderHoldingBall, caughtAFlyBall = + self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) + if caughtAFlyBall then + local fliedOut = self.baserunning:getNewestRunner() + self.baserunning:outRunner(fliedOut, "Fly out!") + self.state.offenseState = C.Offense.fliedOut + self.baserunning:pushNewBatter() + pitchTracker.secondsSinceLastPitch = -1 + end local offenseHandler, defenseHandler = self:currentInputHandlers() @@ -574,7 +595,7 @@ function Game:update() end 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) end diff --git a/src/npc.lua b/src/npc.lua index ad2e247..d7b83a0 100644 --- a/src/npc.lua +++ b/src/npc.lua @@ -1,6 +1,6 @@ local npcBatDeg = 0 -local BaseNpcBatSpeed = 1500 -local npcBatSpeed = 1500 +local BaseNpcBatSpeed = 1000 +local npcBatSpeed = BaseNpcBatSpeed ---@class Npc: InputHandler ---@field runners Runner[] @@ -27,18 +27,24 @@ function Npc.update() end ---@param deltaSec number ---@return number batAngleDeg, number batSpeed 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) else npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed - npcBatDeg = 230 + npcBatDeg = utils.moveAtSpeed1d(npcBatDeg, deltaSec * BaseNpcBatSpeed, 230 - 360) end return npcBatDeg, (self:batSpeed() * deltaSec) end ---@return number function Npc:batSpeed() - return npcBatSpeed / 1.5 + return npcBatSpeed * 1.25 end ---@return number flyTimeMs, number pitchId, number accuracy @@ -131,14 +137,16 @@ end ---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall } local function tryToMakeAPlay(fielders, fielder, runners, ball) local targetX, targetY = getNextOutTarget(runners) - if targetX ~= nil and targetY ~= nil then - local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) - nearestFielder.targets = { utils.xy(targetX, targetY) } - if nearestFielder == fielder then - ball.heldBy = fielder - else - ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true) - end + if targetX == nil or targetY == nil then + return + end + + local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) + nearestFielder.targets = { utils.xy(targetX, targetY) } + if nearestFielder == fielder then + ball.heldBy = fielder + else + ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true) end end @@ -149,14 +157,14 @@ function Npc:fielderAction(fielder, outedSomeRunner, ball) if not fielder then return end - if outedSomeRunner then - -- Delay a little before the next play - playdate.timer.new(750, function() - tryToMakeAPlay(self.fielders, fielder, self.runners, ball) - end) - else + local playDelay = outedSomeRunner and 0.5 or 0.1 + actionQueue:newOnly("npcFielderAction", 2000, function() + local dt = 0 + while dt < playDelay do + dt = dt + coroutine.yield() + end tryToMakeAPlay(self.fielders, fielder, self.runners, ball) - end + end) end ---@return number diff --git a/src/utils.lua b/src/utils.lua index 64f0692..88443fb 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -20,6 +20,25 @@ function utils.easingHill(t, b, c, d) return (c * t) + b 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 { --- currentValue: fun(self): number; --- reset: fun(self, durationMs: number | nil); @@ -63,6 +82,29 @@ function utils.normalizeVector(x1, y1, x2, y2) return x / distance, y / distance, distance 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. --- Stops when within 1. Returns true only if the object did actually move. ---@param mover { x: number, y: number } @@ -88,6 +130,31 @@ function utils.moveAtSpeed(mover, speed, target, tau) return true end +---@param mover Point3d +---@param speed number +---@param target Point3d +---@param tau number | nil +---@return boolean isStillMoving +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 + ---@generic T ---@param array T[] ---@param condition fun(T): boolean @@ -188,22 +255,26 @@ end --- If left of first linePoint and above it, returns true. Similarly if right of the last linePoint. ---@param point XyPair ---@param linePoints XyPair[] ----@return boolean -function utils.pointIsAboveLine(point, linePoints) - if point.x < linePoints[1].x and point.y < linePoints[1].y then - return true +---@return boolean, XyPair | nil nearbyPointAbove +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 local prev = linePoints[i - 1] local next = linePoints[i] 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 - if point.x > linePoints[#linePoints].x and point.y < linePoints[#linePoints].y then - return true + if point.x > linePoints[#linePoints].x and pointY < linePoints[#linePoints].y then + return true, linePoints[#linePoints] end - return false + return false, nil end --- Returns true only if the point is below the given line.