diff --git a/src/main.lua b/src/main.lua index bc453e2..2b440e3 100644 --- a/src/main.lua +++ b/src/main.lua @@ -26,12 +26,14 @@ import 'CoreLibs/ui.lua' --- } --- @alias Fielder { ---- x: number | nil, ---- y: number | nil, +--- x: number, +--- y: number, --- target: XYPair | nil, --- speed: number, --- } +--- @alias EasingFunc fun(number, number, number, number): number + import 'announcer.lua' import 'graphics.lua' import 'scoreboard.lua' @@ -77,6 +79,10 @@ local PitchStartY , PitchEndY = 105, 240 local ballAnimatorY = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear) local ballAnimatorX = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear) +---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self) } +---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil } + +---@type Pitch[] local Pitches = { -- Fastball { @@ -154,13 +160,17 @@ local Bases = { -- Pseudo-base for batter to target local RightHandedBattersBox = xy(Bases[Home].x - 35, Bases[Home].y) ----@type table +---@type table local NextBaseMap = { + [RightHandedBattersBox] = nil, -- Runner should not escape the box before a hit! [Bases[First]] = Bases[Second], [Bases[Second]] = Bases[Third], [Bases[Third]] = Bases[Home], } +---@param name string +---@param speed number +---@return Fielder function newFielder(name, speed) return { name = name, @@ -233,6 +243,12 @@ end local batter = newRunner() --- "Throws" 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 throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) if not flyTimeMs then flyTimeMs = distanceBetween(ball.x, ball.y, destX, destY) * 5 @@ -282,7 +298,11 @@ local crankChange = 0 local acceleratedChange local BaseHitbox = 10 ---- Returns the base being touched by the runner at (x,y), or nil, if no base is being touched + +--- Returns the base being touched by the player at (x,y), or nil, if no base is being touched +---@param x number +---@param y number +---@return Base | nil function isTouchingBase(x, y) for _, base in ipairs(Bases) do if distanceBetween(x, y, base.x, base.y) < BaseHitbox then @@ -294,11 +314,19 @@ function isTouchingBase(x, y) end local BallCatchHitbox = 3 + +--- Returns true only if the given point is touching the ball at its current position +---@param x number +---@param y number +---@return boolean function isTouchingBall(x, y) local ballDistance = distanceBetween(x, y, ball.x, ball.y) return ballDistance < BallCatchHitbox end +---@alias Team { score: number } + +---@type table local teams = { home = { score = 0, @@ -372,7 +400,7 @@ function outRunner(runnerIndex) end end --- TODO: Away score +---@param runnerIndex number function score(runnerIndex) outRunners[#outRunners + 1] = runners[runnerIndex] table.remove(runners, runnerIndex) @@ -427,143 +455,158 @@ function getNextOutTarget() end end -function tryToMakeAnOut(thrower) +---@param fielder Fielder +function tryToMakeAnOut(fielder) local targetX, targetY = getNextOutTarget() if targetX ~= nil and targetY ~= nil then local nearestFielder = getNearestOf(fielders, targetX, targetY) nearestFielder.target = xy(targetX, targetY) - if nearestFielder == thrower then - ball.heldBy = thrower + if nearestFielder == fielder then + ball.heldBy = fielder else throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true) end end 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 } +---@param speed number +---@param target { x: number, y: number } +---@return boolean +function moveAtSpeed(mover, speed, target) + local x, y, distance = normalizeVector(mover.x, mover.y, target.x, target.y) + + if distance > 1 then + mover.x = mover.x - (x * speed) + mover.y = mover.y - (y * speed) + return true + end + + return false +end + +---@param fielder Fielder +---@param runnerBaseCache Cache +function updateFielder(fielder, runnerBaseCache) + -- TODO: Target unforced runners (or their target bases) for tagging + -- With new Position-based scheme, fielders are now able to set `fielder.target = runner` to track directly + + if fielder.target ~= nil then + if not moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then + fielder.target = nil + end + end + + if currentMode ~= Modes.running or not isTouchingBall(fielder.x, fielder.y) then + return + end + + -- TODO: Check for double-plays or other available outs. + local touchedBase = isTouchingBase(fielder.x, fielder.y) + for i, runner in pairs(runners) do + if + ( -- Force out + touchedBase + -- and runner.prevBase -- Make sure the runner is not standing at home + and runner.forcedTo == touchedBase + and touchedBase ~= runnerBaseCache.get(runner) + ) + or ( -- Tag out + not runnerBaseCache.get(runner) + and distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < TagDistance + ) + then + outRunner(i) + playdate.timer.new(750, function() + tryToMakeAnOut(fielder) + end) + else + tryToMakeAnOut(fielder) + end + end +end + function updateFielders() - local touchingBaseCache = buildCache(function(runner) + local runnerBaseCache = buildCache(function(runner) return isTouchingBase(runner.x, runner.y) end) for _, fielder in pairs(fielders) do - -- TODO: Target unforced runners (or their target bases) for tagging - -- With new Position-based scheme, fielders are now able to set `fielder.target = runner` to track directly - - if fielder.target ~= nil then - local x, y, distance = normalizeVector(fielder.x, fielder.y, fielder.target.x, fielder.target.y) - - if distance > 1 then - fielder.x = fielder.x - (x * fielder.speed * deltaSeconds) - fielder.y = fielder.y - (y * fielder.speed * deltaSeconds) - else - fielder.target = nil - end - end - - if currentMode == Modes.running and isTouchingBall(fielder.x, fielder.y) then - -- TODO: Check for double-plays or other available outs. - local touchedBase = isTouchingBase(fielder.x, fielder.y) - for i, runner in pairs(runners) do - if - ( -- Force out - touchedBase - and runner.prevBase -- Make sure the runner is not standing at home - and runner.forcedTo == touchedBase - and touchedBase ~= touchingBaseCache.get(runner) - ) - or ( -- Tag out - not touchingBaseCache.get(runner) - and distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < TagDistance - ) - then - outRunner(i) - playdate.timer.new(750, function() - tryToMakeAnOut(fielder) - end) - else - tryToMakeAnOut(fielder) - end - end - end + updateFielder(fielder, runnerBaseCache) end + + -- if currentMode == Modes.batting then + -- moveAtSpeed( + -- fielders.catcher, + -- fielders.catcher.speed * 2 * deltaSeconds, + -- { x = math.min(Center.x + 15, ball.x), y = fielders.catcher.y } + -- ) + -- end end ---- Returns true if at least one runner is still moving +--- Returns true only if the given runner moved during this update. +---@param runner Runner | nil +---@param runnerIndex integer | nil May only be nil if runner == batter ---@return boolean -function updateRunners(currentRunners) +function updateRunner(runner, runnerIndex) local autoRunSpeed = 20 * deltaSeconds --autoRunSpeed = 140 - -- TODO: Filter for the runner closest to the currently-held direction button - local runnerMoved = false - for runnerIndex, runner in ipairs(currentRunners) do - local appliedSpeed = crankChange -- TODO: Allow for individual runner control via buttons - local nearestBase, nearestBaseDistance = getNearestOf(Bases, runner.x, runner.y) + if not runner or not runner.nextBase then + return false + end - if - nearestBaseDistance < 5 - and runner.prevBase - and runner.nextBase == Bases[Home] - and nearestBase == Bases[Home] - then - score(runnerIndex) - end + local appliedSpeed = crankChange -- TODO: Allow for individual runner control via buttons + local nearestBase, nearestBaseDistance = getNearestOf(Bases, runner.x, runner.y) - if runner.nextBase then - local nb = runner.nextBase - local x, y, distance = normalizeVector(runner.x, runner.y, nb.x, nb.y) + if + nearestBaseDistance < 5 + and runnerIndex ~= nil + and runner ~= batter --runner.prevBase + and runner.nextBase == Bases[Home] + and nearestBase == Bases[Home] + then + score(runnerIndex) + end - if distance > 1 then - local prevX, prevY = runner.x, runner.y - local mult = 1 - if appliedSpeed < 0 then - if runner.prevBase then - mult = -1 - else - -- Don't allow running backwards when approaching the plate - appliedSpeed = 0 - end - end + local nb = runner.nextBase + local x, y, distance = normalizeVector(runner.x, runner.y, nb.x, nb.y) - -- TODO: Also move if forced to 😅 - local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed - or nearestBaseDistance < 5 and 0 - or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed) - mult = autoRun + (appliedSpeed / 20) - runner.x = runner.x - (x * mult) - runner.y = runner.y - (y * mult) + if distance < 1 then + runner.nextBase = NextBaseMap[runner.nextBase] + runner.forcedTo = nil + return false + end - runnerMoved = runnerMoved or prevX ~= runner.x or prevY ~= runner.y - else - runner.nextBase = NextBaseMap[runner.nextBase] - runner.forcedTo = nil - end + local prevX, prevY = runner.x, runner.y + local mult = 1 + if appliedSpeed < 0 then + if runner.prevBase then + mult = -1 + else + -- Don't allow running backwards when approaching the plate + appliedSpeed = 0 end end - return runnerMoved + -- TODO: Also move if forced to 😅 + local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed + or nearestBaseDistance < 5 and 0 + or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed) + mult = autoRun + (appliedSpeed / 20) + runner.x = runner.x - (x * mult) + runner.y = runner.y - (y * mult) + + return prevX ~= runner.x or prevY ~= runner.y end -local ResetFieldersAfterSeconds = 2 --- TODO: Replace with a timer, repeatedly reset instead of setting to 0 +local ResetFieldersAfterSeconds = 5 +-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0 local secondsSinceLastRunnerMove = 0 -function init() - playdate.display.setRefreshRate(50) - gfx.setBackgroundColor(gfx.kColorWhite) - playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) - resetFielderPositions(true) - playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? - - playdate.timer.new(2000, function() - throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, false) - end) - BootTune:play() - BootTune:setFinishCallback(function() - TinnyBackground:play() - end) -end - +---@type number local batAngleDeg function updateBatting() @@ -581,8 +624,9 @@ function updateBatting() batTip.y = batBase.y + (BatLength * math.cos(batAngle)) if - acceleratedChange >= 0 + acceleratedChange > 0 and pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H) + and ball.y < 232 --not isTouchingBall(fielders.catcher.x, fielders.catcher.y) then BatCrackSound:play() currentMode = Modes.running @@ -619,25 +663,27 @@ function updateBatting() end end +--- Update non-batter runners. +--- Returns true only if at least one of the given runners moved during this update +---@return boolean function updateRunning() + ball.size = ballSizeAnimator:currentValue() + local nonBatterRunners = filter(runners, function(runner) return runner ~= batter end) - ball.size = ballSizeAnimator:currentValue() - if updateRunners(nonBatterRunners) then - secondsSinceLastRunnerMove = 0 - else - secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds - if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then - throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true) - resetFielderPositions(false) - currentMode = Modes.batting - batter = newRunner() - end + + local runnerMoved = false + + -- TODO: Filter for the runner closest to the currently-held direction button + for runnerIndex, runner in ipairs(nonBatterRunners) do + runnerMoved = updateRunner(runner, runnerIndex) or runnerMoved end + + return runnerMoved end -function updateOutRunners() +function walkAwayOutRunners() for i, runner in ipairs(outRunners) do if runner.x < Screen.W + 50 and runner.y < Screen.H + 50 then runner.x = runner.x + (deltaSeconds * 25) @@ -672,13 +718,24 @@ function updateGameState() pitch() end updateBatting() - updateRunners({ batter }) + -- TODO: Ensure batter can't be nil, here + updateRunner(batter) elseif currentMode == Modes.running then - updateRunning() + if updateRunning() then + secondsSinceLastRunnerMove = 0 + else + secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds + if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then + throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true) + resetFielderPositions(false) + currentMode = Modes.batting + batter = newRunner() + end + end end updateFielders() - updateOutRunners() + walkAwayOutRunners() end -- TODO @@ -741,11 +798,27 @@ function playdate.update() gfx.drawCircleAtPoint(ball.x, ball.y, ball.size) gfx.setDrawOffset(0, 0) - if offsetX > 0 or offsetY > 0 then + if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then drawMinimap() end drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning) announcer:draw(Center.x, 10) end +function init() + playdate.display.setRefreshRate(50) + gfx.setBackgroundColor(gfx.kColorWhite) + playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) + resetFielderPositions(true) + playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? + + playdate.timer.new(2000, function() + throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, false) + end) + BootTune:play() + BootTune:setFinishCallback(function() + TinnyBackground:play() + end) +end + init() diff --git a/src/utils.lua b/src/utils.lua index e5a9df2..065d581 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -34,7 +34,11 @@ function xy(x, y) end --- Returns the normalized vector as two values, plus the distance between the given points. ----@return number, number, number +---@param x1 number +---@param y1 number +---@param x2 number +---@param y2 number +---@return number x, number y, number distance function normalizeVector(x1, y1, x2, y2) local distance, a, b = distanceBetween(x1, y1, x2, y2) return a / distance, b / distance, distance @@ -108,6 +112,8 @@ function getNearestOf(array, x, y, extraCondition) return nearest, nearestDistance end +---@alias Cache { get: fun(key: `Key`): `Value` } + --- Marker used by buildCache to indicate a cached `nil` value. local NoValue = {} @@ -120,7 +126,7 @@ local NoValue = {} --- ---@generic Key ---@generic Value ----@return { get: fun(key: Key): Value } +---@return Cache function buildCache(fetcher) local cacheData = {} return {