From 3e256d95c93ba83b294f0dbfd88244d3a412c12a Mon Sep 17 00:00:00 2001 From: Sage Vaillancourt Date: Sun, 16 Feb 2025 18:34:45 -0500 Subject: [PATCH] Committing my "realer" physics changes as they stand Which is to say, *very, very, unplayable* --- src/ball.lua | 209 +++++++++++++++++++++++++++++++------- src/baserunning.lua | 1 + src/constants.lua | 6 +- src/draw/overlay.lua | 1 - src/draw/player.lua | 3 - src/fielding.lua | 97 +++++++++++++----- src/graphics.lua | 4 +- src/main-menu.lua | 1 + src/main.lua | 112 ++++++++++++-------- src/npc.lua | 15 +-- src/test/testFielding.lua | 5 + src/utils.lua | 29 ++++++ 12 files changed, 362 insertions(+), 121 deletions(-) diff --git a/src/ball.lua b/src/ball.lua index 9e3d040..bac9918 100644 --- a/src/ball.lua +++ b/src/ball.lua @@ -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 diff --git a/src/baserunning.lua b/src/baserunning.lua index 619e779..b958cca 100644 --- a/src/baserunning.lua +++ b/src/baserunning.lua @@ -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 diff --git a/src/constants.lua b/src/constants.lua index 177fbcd..9077ca3 100644 --- a/src/constants.lua +++ b/src/constants.lua @@ -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 diff --git a/src/draw/overlay.lua b/src/draw/overlay.lua index a1288db..470a2fa 100644 --- a/src/draw/overlay.lua +++ b/src/draw/overlay.lua @@ -1,4 +1,3 @@ --- selene: allow(shadowing) local gfx = playdate.graphics local ScoreFont = playdate.graphics.font.new("fonts/font-full-circle.pft") diff --git a/src/draw/player.lua b/src/draw/player.lua index 6426081..52e9c56 100644 --- a/src/draw/player.lua +++ b/src/draw/player.lua @@ -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 diff --git a/src/fielding.lua b/src/fielding.lua index e755987..c782514 100644 --- a/src/fielding.lua +++ b/src/fielding.lua @@ -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 ---@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 diff --git a/src/graphics.lua b/src/graphics.lua index a5bfb95..95215a0 100644 --- a/src/graphics.lua +++ b/src/graphics.lua @@ -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 diff --git a/src/main-menu.lua b/src/main-menu.lua index f0afb97..aa60960 100644 --- a/src/main-menu.lua +++ b/src/main-menu.lua @@ -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 diff --git a/src/main.lua b/src/main.lua index 268012c..c25b684 100644 --- a/src/main.lua +++ b/src/main.lua @@ -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 diff --git a/src/npc.lua b/src/npc.lua index 812be91..3af7d51 100644 --- a/src/npc.lua +++ b/src/npc.lua @@ -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 diff --git a/src/test/testFielding.lua b/src/test/testFielding.lua index 73c719e..d4b527b 100644 --- a/src/test/testFielding.lua +++ b/src/test/testFielding.lua @@ -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 diff --git a/src/utils.lua b/src/utils.lua index 589fbaf..d1b225e 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -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