-- stylua: ignore start import 'CoreLibs/animation.lua' import 'CoreLibs/animator.lua' import 'CoreLibs/easing.lua' import 'CoreLibs/graphics.lua' import 'CoreLibs/object.lua' import 'CoreLibs/timer.lua' import 'CoreLibs/ui.lua' --- @alias Runner { --- x: number, --- y: number, --- nextBase: Base, --- prevBase: Base | nil, --- forcedTo: Base | nil, --- } --- @alias Fielder { --- x: number, --- y: number, --- target: XYPair | nil, --- speed: number, --- } --- @alias EasingFunc fun(number, number, number, number): number import 'utils.lua' import 'constants.lua' import 'assets.lua' import 'announcer.lua' import 'dbg.lua' import 'graphics.lua' import 'npc.lua' import 'draw/overlay' import 'draw/fielder' -- stylua: ignore end -- selene: allow(shadowing) local gfx , C = playdate.graphics, C local BootTune = playdate.sound.sampleplayer.new("sounds/boot-tune.wav") -- local BootTune = playdate.sound.sampleplayer.new("sounds/boot-tune-organy.wav") local TinnyBackground = playdate.sound.sampleplayer.new("sounds/tinny-background.wav") local BatCrackSound = playdate.sound.sampleplayer.new("sounds/bat-crack-reverb.wav") local PlayerImageBlipper = blipper.new(100, Player, PlayerLowHat) local FielderDanceAnimator = gfx.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1 -- selene: allow(unused_variable) function fieldersDance() FielderDanceAnimator:reset(C.DanceBounceMs) end local ballAnimatorY = gfx.animator.new(0, C.BallOffscreen, C.BallOffscreen, playdate.easingFunctions.linear) local ballAnimatorX = gfx.animator.new(0, C.BallOffscreen, C.BallOffscreen, playdate.easingFunctions.linear) ---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil } ---@type Pitch[] local Pitches = { -- Fastball { x = gfx.animator.new(0, C.PitchStartX, C.PitchStartX, playdate.easingFunctions.linear), y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), }, -- Curve ball { x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX + 20, C.PitchStartX, utils.easingHill), y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), }, -- Slider { x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX - 20, C.PitchStartX, utils.easingHill), y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), }, -- Wobbbleball { x = { currentValue = function() return C.PitchStartX + (10 * math.sin((ballAnimatorY:currentValue() - C.PitchStartY) / 10)) end, reset = function() end, }, y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), }, } local BatterHandPos = utils.xy(10, 25) local batBase = utils.xy(C.Center.x - 34, 215) local batTip = utils.xy(0, 0) local ball = { x = C.Center.x --[[@as number]], y = C.Center.y --[[@as number]], z = 0, size = C.SmallestBallRadius, heldBy = nil --[[@type Runner | nil]], } ---@alias Team { score: number, benchPosition: XYPair } ---@type table local teams = { home = { score = 0, benchPosition = utils.xy(C.Screen.W + 10, C.Center.y), }, away = { score = 0, benchPosition = utils.xy(-10, C.Center.y), }, } local PlayerTeam = teams.home local battingTeam = teams.away local outs = 0 local inning = 1 local offenseMode = C.Offense.batting ---@return boolean playerIsOnSide, boolean playerIsOnOtherSide function playerIsOn(side) local ret if PlayerTeam == battingTeam then ret = side == C.Sides.offense else ret = side == C.Sides.defense end return ret, not ret end -- TODO? Replace this AND ballSizeAnimator with a ballHeightAnimator -- ...that might lose some of the magic of both. Compromise available? idk local ballFloatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill) local BallSizeMs = 2000 local ballSizeAnimator = gfx.animator.new(BallSizeMs, 9, C.SmallestBallRadius, utils.easingHill) local deltaSeconds = 0 ---@param name string ---@param speed number ---@return Fielder function newFielder(name, speed) return { name = name, speed = speed, } end ---@type table local fielders = { first = newFielder("First", 40), second = newFielder("Second", 40), shortstop = newFielder("Shortstop", 40), third = newFielder("Third", 40), pitcher = newFielder("Pitcher", 30), catcher = newFielder("Catcher", 35), left = newFielder("Left", 40), center = newFielder("C.Center", 40), right = newFielder("Right", 40), } --- Actually only benches the infield, because outfielders are far away! ---@param position XYPair function benchAllFielders(position) fielders.first.target = position fielders.second.target = position fielders.shortstop.target = position fielders.third.target = position fielders.pitcher.target = position fielders.catcher.target = position 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 resetFielderPositions(fromOffTheField) if fromOffTheField then for _, fielder in pairs(fielders) do fielder.x = fromOffTheField.x fielder.y = fromOffTheField.y end end fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y) fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) fielders.left.target = utils.xy(C.Screen.W * -1, C.Screen.H * -0.2) fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4) fielders.right.target = utils.xy(C.Screen.W * 2, fielders.left.target.y) end --- @type Runner[] local runners = {} --- @type Runner[] local outRunners = {} ---@return Runner function newRunner() local new = { x = C.RightHandedBattersBox.x - 60, y = C.RightHandedBattersBox.y + 60, nextBase = C.RightHandedBattersBox, prevBase = nil, forcedTo = C.Bases[C.First], } runners[#runners + 1] = new return new end ---@type Runner | nil local batter = newRunner() local throwMeter = 0 --- "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) ball.heldBy = nil throwMeter = 0 if not flyTimeMs then flyTimeMs = utils.distanceBetween(ball.x, ball.y, destX, destY) * 5 end if customBallScaler then ballSizeAnimator = customBallScaler else -- TODO? Scale based on distance? ballSizeAnimator = gfx.animator.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill) end ballAnimatorY = gfx.animator.new(flyTimeMs, ball.y, destY, easingFunc) ballAnimatorX = gfx.animator.new(flyTimeMs, ball.x, destX, easingFunc) if floaty then ballFloatAnimator:reset(flyTimeMs) end end -- TODO: Replace with a timer, repeatedly reset instead of setting to 0 local secondsSincePitchAllowed = -5 local catcherThrownBall = false ---@param pitchFlyTimeMs number | nil ---@param pitchTypeIndex number | nil function pitch(pitchFlyTimeMs, pitchTypeIndex) catcherThrownBall = false offenseMode = C.Offense.batting local current = Pitches[pitchTypeIndex] ballAnimatorX = current.x ballAnimatorY = current.y or Pitches[1].y -- TODO: This would need to be sanely replaced in throwBall() etc. -- if current.z then -- ballFloatAnimator = current.z -- ballFloatAnimator:reset() -- end if pitchFlyTimeMs then ballAnimatorX:reset(pitchFlyTimeMs) ballAnimatorY:reset(pitchFlyTimeMs) else ballAnimatorX:reset() ballAnimatorY:reset() end secondsSincePitchAllowed = 0 end --- 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) return utils.first(C.Bases, function(base) return utils.distanceBetween(x, y, base.x, base.y) < C.BaseHitbox end) 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, number function isTouchingBall(x, y) local ballDistance = utils.distanceBetween(x, y, ball.x, ball.y) return ballDistance < BallCatchHitbox, ballDistance end ---@param base Base ---@return Runner | nil function getRunnerWithNextBase(base) return utils.first(runners, function(runner) return runner.nextBase == base end) end function updateForcedRunners() local stillForced = true for _, base in ipairs(C.Bases) do local runnerTargetingBase = getRunnerWithNextBase(base) if runnerTargetingBase then if stillForced then runnerTargetingBase.forcedTo = base else runnerTargetingBase.forcedTo = nil end else stillForced = false end end end local ResetFieldersAfterSeconds = 5 -- TODO: Replace with a timer, repeatedly reset, instead of setting to 0 local secondsSinceLastRunnerMove = 0 ---@param runner integer | Runner function outRunner(runner, message) if type(runner) ~= "number" then for i, maybe in ipairs(runners) do if runner == maybe then runner = i end end end if type(runner) ~= "number" then error("Expected runner to have type 'number', but was: " .. type(runner)) end outRunners[#outRunners + 1] = runners[runner] table.remove(runners, runner) outs = outs + 1 updateForcedRunners() announcer:say(message or "YOU'RE OUT!") if outs == 3 then local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home local gameOver = inning == 9 and teams.away.score ~= teams.home.score if not gameOver then fieldersDance() secondsSinceLastRunnerMove = -7 benchAllFielders(currentlyFieldingTeam.benchPosition) announcer:say("SWITCHING SIDES...") end while #runners > 0 do outRunners[#outRunners + 1] = table.remove(runners, #runners) end -- Delay to keep end-of-inning on the scoreboard for a few seconds playdate.timer.new(3000, function() outs = 0 battingTeam = currentlyFieldingTeam if gameOver then announcer:say("AND THAT'S THE BALL GAME!") else resetFielderPositions() if battingTeam == teams.home then inning = inning + 1 end end end) end end ---@param runnerIndex number function score(runnerIndex) outRunners[#outRunners + 1] = runners[runnerIndex] table.remove(runners, runnerIndex) battingTeam.score = battingTeam.score + 1 announcer:say("SCORE!") end ---@return Base[] function getForcedOutTargets() local targets = {} for _, base in ipairs(C.Bases) do local runnerTargetingBase = getRunnerWithNextBase(base) if runnerTargetingBase then targets[#targets + 1] = base else return targets end end return targets end --- Returns the position,distance of the basest closest to the runner furthest from a base ---@return Base | nil, number | nil function getBaseOfStrandedRunner() local farRunnersBase, farDistance for _, runner in pairs(runners) do if runner ~= batter then local nearestBase, distance = utils.getNearestOf(C.Bases, runner.x, runner.y) if farRunnersBase == nil or farDistance < distance then farRunnersBase = nearestBase farDistance = distance end end end return farRunnersBase, farDistance end --- Returns x,y of the out target ---@return number|nil, number|nil function getNextOutTarget() -- TODO: Handle missed throws, check for fielders at target, etc. local targets = getForcedOutTargets() if #targets ~= 0 then return targets[#targets].x, targets[#targets].y end local baseCloseToStrandedRunner = getBaseOfStrandedRunner() if baseCloseToStrandedRunner then return baseCloseToStrandedRunner.x, baseCloseToStrandedRunner.y end end ---@param fielder Fielder function tryToMakeAnOut(fielder) local targetX, targetY = getNextOutTarget() if targetX ~= nil and targetY ~= nil then local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) nearestFielder.target = utils.xy(targetX, targetY) if nearestFielder == fielder then ball.heldBy = fielder else throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true) end end end function readThrow() if throwMeter > C.ThrowMeterMax then return (C.PitchFlyMs / (throwMeter / C.ThrowMeterMax)) end return nil end ---@param thrower Fielder ---@param throwFlyMs number ---@return boolean didThrow function buttonControlledThrow(thrower, throwFlyMs, forbidThrowHome) local targetBase if playdate.buttonIsPressed(playdate.kButtonLeft) then targetBase = C.Bases[C.Third] elseif playdate.buttonIsPressed(playdate.kButtonUp) then targetBase = C.Bases[C.Second] elseif playdate.buttonIsPressed(playdate.kButtonRight) then targetBase = C.Bases[C.First] elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then targetBase = C.Bases[C.Home] else return false end local closestFielder = utils.getNearestOf(fielders, targetBase.x, targetBase.y, function(fielder) return fielder ~= thrower end) throwBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) closestFielder.target = targetBase secondsSinceLastRunnerMove = 0 offenseMode = C.Offense.running return true end function outEligibleRunners(fielder) local touchedBase = isTouchingBase(fielder.x, fielder.y) local didOutRunner = false for i, runner in pairs(runners) do local runnerOnBase = isTouchingBase(runner.x, runner.y) if -- Force out touchedBase and runner.prevBase -- Make sure the runner is not standing at home and runner.forcedTo == touchedBase and touchedBase ~= runnerOnBase -- Tag out or not runnerOnBase and utils.distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < C.TagDistance then outRunner(i) didOutRunner = true end end return didOutRunner end function updateNpcFielder(fielder, outedSomeRunner) if offenseMode ~= C.Offense.running then return end if outedSomeRunner then playdate.timer.new(750, function() tryToMakeAnOut(fielder) end) else tryToMakeAnOut(fielder) end end ---@param fielder Fielder function updateFielder(fielder) if fielder.target ~= nil then if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then fielder.target = nil end end if not isTouchingBall(fielder.x, fielder.y) then return end local outedSomeRunner = outEligibleRunners(fielder) if playerIsOn(C.Sides.defense) then local throwFly = readThrow() if throwFly then buttonControlledThrow(fielders.pitcher, throwFly) end else updateNpcFielder(fielder, outedSomeRunner) end end --- 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 ---@param appliedSpeed number ---@return boolean function updateRunner(runner, runnerIndex, appliedSpeed) local autoRunSpeed = 20 * deltaSeconds --autoRunSpeed = 140 if not runner or not runner.nextBase then return false end local nearestBase, nearestBaseDistance = utils.getNearestOf(C.Bases, runner.x, runner.y) if nearestBaseDistance < 5 and runnerIndex ~= nil and runner ~= batter --runner.prevBase and runner.nextBase == C.Bases[C.Home] and nearestBase == C.Bases[C.Home] then score(runnerIndex) end local nb = runner.nextBase local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y) if distance < 2 then runner.nextBase = C.NextBaseMap[runner.nextBase] runner.forcedTo = nil return false 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 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 ---@type number local batAngleDeg function nextBatter() batter = nil playdate.timer.new(2000, function() pitchTracker:reset() if not batter then batter = newRunner() end end) end function walk() announcer:say("Walk!") fielders.first.target = C.Bases[C.First] batter.nextBase = C.Bases[C.First] batter.prevBase = C.Bases[C.Home] offenseMode = C.Offense.walking batter = nil updateForcedRunners() nextBatter() end function strikeOut() local outBatter = batter batter = nil outRunner(outBatter --[[@as Runner]], "Strike out!") nextBatter() end ---@param batDeg number function updateBatting(batDeg, batSpeed) if ball.y < C.BallOffscreen then ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue() ball.size = C.SmallestBallRadius -- ballFloatAnimator:currentValue() end local batAngle = math.rad(batDeg) -- TODO: animate bat-flip or something batBase.x = batter and (batter.x + BatterHandPos.x) or 0 batBase.y = batter and (batter.y + BatterHandPos.y) or 0 batTip.x = batBase.x + (C.BatLength * math.sin(batAngle)) batTip.y = batBase.y + (C.BatLength * math.cos(batAngle)) if batSpeed > 0 and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H) and ball.y < 232 then BatCrackSound:play() offenseMode = C.Offense.running 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 = ball.x + (ballVelX * C.BattingPower) local ballDestY = ball.y + (ballVelY * C.BattingPower) -- Hit! local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) throwBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler) fielders.first.target = C.Bases[C.First] batter.nextBase = C.Bases[C.First] batter.prevBase = C.Bases[C.Home] updateForcedRunners() batter.forcedTo = C.Bases[C.First] batter = nil -- Demote batter to a mere runner local chasingFielder = utils.getNearestOf(fielders, ballDestX, ballDestY) chasingFielder.target = { x = ballDestX, y = ballDestY } end end --- Update non-batter runners. --- Returns true only if at least one of the given runners moved during this update ---@param appliedSpeed number ---@return boolean function updateRunning(appliedSpeed, forcedOnly) ball.size = ballSizeAnimator:currentValue() local runnerMoved = false -- TODO: Filter for the runner closest to the currently-held direction button for runnerIndex, runner in ipairs(runners) do if runner ~= batter and (not forcedOnly or runner.forcedTo) then runnerMoved = updateRunner(runner, runnerIndex, appliedSpeed) or runnerMoved end end return runnerMoved end function walkAwayOutRunners() for i, runner in ipairs(outRunners) do if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then runner.x = runner.x + (deltaSeconds * 25) runner.y = runner.y + (deltaSeconds * 25) else table.remove(outRunners, i) end end end function playerPitch(throwFly) local aButton = playdate.buttonIsPressed(playdate.kButtonA) local bButton = playdate.buttonIsPressed(playdate.kButtonB) if not aButton and not bButton then pitch(throwFly, 1) elseif aButton and not bButton then pitch(throwFly, 2) elseif not aButton and bButton then pitch(throwFly, 3) elseif aButton and bButton then pitch(throwFly, 4) end end function updateGameState() deltaSeconds = playdate.getElapsedTime() or 0 playdate.resetElapsedTime() local crankChange = playdate.getCrankChange() --[[@as number]] local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower) if crankChange < 0 then crankLimited = crankLimited * -1 end if ball.heldBy then ball.x = ball.heldBy.x ball.y = ball.heldBy.y else ball.x = ballAnimatorX:currentValue() ball.z = ballFloatAnimator:currentValue() ball.y = ballAnimatorY:currentValue() + ball.z end local playerOnOffense, playerOnDefense = playerIsOn(C.Sides.offense) if playerOnDefense then throwMeter = math.max(0, throwMeter - (deltaSeconds * C.ThrowMeterDrainPerSec)) throwMeter = throwMeter + math.abs(crankLimited) end if offenseMode == C.Offense.batting then if ball.y < C.StrikeZoneStartY then pitchTracker.recordedPitchX = nil elseif not pitchTracker.recordedPitchX then pitchTracker.recordedPitchX = ball.x end local pitcher = fielders.pitcher if utils.distanceBetween(pitcher.x, pitcher.y, C.PitchStartX, C.PitchStartY) < C.BaseHitbox then secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds end if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then local outcome = pitchTracker:updatePitchCounts() if outcome == PitchOutcomes.StrikeOut then strikeOut() elseif outcome == PitchOutcomes.Walk then walk() end throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) catcherThrownBall = true end local batSpeed if playerOnOffense then batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 batSpeed = crankLimited else batAngleDeg = npc.updateBatAngle(ball, catcherThrownBall, deltaSeconds) batSpeed = npc.batSpeed() * deltaSeconds end updateBatting(batAngleDeg, batSpeed) -- TODO: Ensure batter can't be nil, here updateRunner(batter, nil, crankLimited) if secondsSincePitchAllowed > C.PitchAfterSeconds then if playerOnDefense then local throwFly = readThrow() if throwFly and not buttonControlledThrow(pitcher, throwFly, true) then playerPitch(throwFly) end else pitch(C.PitchFlyMs, math.random(#Pitches)) end end elseif offenseMode == C.Offense.running then local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(runners) if updateRunning(appliedSpeed) then secondsSinceLastRunnerMove = 0 else secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) resetFielderPositions() offenseMode = C.Offense.batting if not batter then batter = newRunner() end end end elseif offenseMode == C.Offense.walking then updateForcedRunners() if not updateRunning(C.WalkedRunnerSpeed, true) then offenseMode = C.Offense.batting end end for _, fielder in pairs(fielders) do updateFielder(fielder) end walkAwayOutRunners() end function playdate.update() playdate.timer.updateTimers() gfx.animation.blinker.updateAll() updateGameState() gfx.clear() gfx.setColor(gfx.kColorBlack) local offsetX, offsetY = 0, 0 if ball.x < C.BallOffscreen then offsetX, offsetY = getDrawOffset(C.Screen.W, C.Screen.H, ball.x, ball.y) gfx.setDrawOffset(offsetX, offsetY) end GrassBackground:draw(-400, -240) local fielderDanceHeight = FielderDanceAnimator:currentValue() local ballIsHeld = false for _, fielder in pairs(fielders) do ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld end if offenseMode == C.Offense.batting then gfx.setLineWidth(5) gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y) end if playdate.isCrankDocked() then playdate.ui.crankIndicator:draw() end -- TODO? Scale sprites down as y increases for _, runner in pairs(runners) do if runner == batter then if batAngleDeg > 50 and batAngleDeg < 200 then PlayerBack:draw(runner.x, runner.y) else Player:draw(runner.x, runner.y) end else -- TODO? Change blip speed depending on runner speed? PlayerImageBlipper:draw(false, runner.x, runner.y) end end for _, runner in pairs(outRunners) do PlayerFrown:draw(runner.x, runner.y) end if not ballIsHeld then gfx.setLineWidth(2) gfx.setColor(gfx.kColorWhite) gfx.fillCircleAtPoint(ball.x, ball.y, ball.size) gfx.setColor(gfx.kColorBlack) gfx.drawCircleAtPoint(ball.x, ball.y, ball.size) end gfx.setDrawOffset(0, 0) if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then drawMinimap(runners) end drawScoreboard(0, C.Screen.H * 0.77, teams, outs, battingTeam, inning) drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes) announcer:draw(C.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(teams.home.benchPosition) playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? playdate.timer.new(2000, function() throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false) end) BootTune:play() BootTune:setFinishCallback(function() TinnyBackground:play() end) end init()