-- 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 XYPair { --- x: number, --- y: number, --- } --- @alias Base { --- x: number, --- y: number, --- } --- @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 'announcer.lua' import 'dbg.lua' import 'graphics.lua' import 'scoreboard.lua' import 'utils.lua' -- stylua: ignore end local gfx = playdate.graphics local Screen = { W = playdate.display.getWidth(), H = playdate.display.getHeight(), } local Center = utils.xy(Screen.W / 2, Screen.H / 2) 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 GrassBackground = gfx.image.new("images/game/grass.png") --[[@as pd_image]] local Minimap = gfx.image.new("images/game/minimap.png") --[[@as pd_image]] local PlayerFrown = gfx.image.new("images/game/player-frown.png") --[[@as pd_image]] local PlayerSmile = gfx.image.new("images/game/player.png") --[[@as pd_image]] local PlayerBack = gfx.image.new("images/game/player-back.png") --[[@as pd_image]] local Glove = gfx.image.new("images/game/glove.png") --[[@as pd_image]] local GloveHoldingBall = gfx.image.new("images/game/glove-holding-ball.png") --[[@as pd_image]] local GloveSizeX, GloveSizeY = Glove:getSize() local GloveOffX, GloveOffY = GloveSizeX / 2, GloveSizeY / 2 local PlayerImageBlipper = blipper.new(100, "images/game/player.png", "images/game/player-lowhat.png") local DanceBounceMs = 500 local DanceBounceCount = 4 local FielderDanceAnimator = gfx.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator.repeatCount = DanceBounceCount - 1 -- selene: allow(unused_variable) function fieldersDance() FielderDanceAnimator:reset(DanceBounceMs) end local BallOffscreen = 999 local PitchFlyMs = 1050 local PitchStartX = 195 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, durationMs: number | nil) } ---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil } ---@type Pitch[] local Pitches = { -- Fastball { x = gfx.animator.new(0, PitchStartX, PitchStartX, playdate.easingFunctions.linear), y = gfx.animator.new(PitchFlyMs / 1.3, PitchStartY, PitchEndY, playdate.easingFunctions.linear), }, -- Curve ball { x = gfx.animator.new(PitchFlyMs, PitchStartX + 20, PitchStartX, utils.easingHill), y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear), }, -- Slider { x = gfx.animator.new(PitchFlyMs, PitchStartX - 20, PitchStartX, utils.easingHill), y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear), }, -- Wobbbleball { x = { currentValue = function() return PitchStartX + (10 * math.sin((ballAnimatorY:currentValue() - PitchStartY) / 10)) end, reset = function() end, }, y = gfx.animator.new(PitchFlyMs * 1.3, PitchStartY, PitchEndY, playdate.easingFunctions.linear), }, } local CrankOffsetDeg = 90 local BatOffset = utils.xy(10, 25) local batBase = utils.xy(Center.x - 34, 215) local batTip = utils.xy(0, 0) local TagDistance = 15 local SmallestBallRadius = 6 local ball = { x = Center.x --[[@as number]], y = Center.y --[[@as number]], size = SmallestBallRadius, heldBy = nil --[[@type Runner | nil]], } local BatLength = 50 local Offense = { batting = {}, running = {}, } local Sides = { offense = {}, defense = {}, } local offenseMode = Offense.batting ---@alias Team { score: number, benchPosition: XYPair } ---@type table local teams = { home = { score = 0, benchPosition = utils.xy(Screen.W + 10, Center.y), }, away = { score = 0, benchPosition = utils.xy(-10, Center.y), }, } local PlayerTeam = teams.home local battingTeam = teams.away local outs = 0 local inning = 1 ---@return boolean playerIsOnSide, boolean playerIsOnOtherSide function playerIsOn(side) local ret if PlayerTeam == battingTeam then ret = side == Sides.offense else ret = side == 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, SmallestBallRadius, utils.easingHill) local HitMult = 20 local deltaSeconds = 0 local First , Second , Third , Home = 1, 2, 3, 4 ---@type Base[] local Bases = { utils.xy(Screen.W * 0.93, Screen.H * 0.52), utils.xy(Screen.W * 0.47, Screen.H * 0.19), utils.xy(Screen.W * 0.03, Screen.H * 0.52), utils.xy(Screen.W * 0.474, Screen.H * 0.79), } -- Pseudo-base for batter to target local RightHandedBattersBox = utils.xy(Bases[Home].x - 35, Bases[Home].y) ---@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, 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("Center", 40), right = newFielder("Right", 40), } local PitcherStartPos = { x = Screen.W * 0.48, y = Screen.H * 0.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(Screen.W - 65, Screen.H * 0.48) fielders.second.target = utils.xy(Screen.W * 0.70, Screen.H * 0.30) fielders.shortstop.target = utils.xy(Screen.W * 0.30, Screen.H * 0.30) fielders.third.target = utils.xy(Screen.W * 0.1, Screen.H * 0.48) fielders.pitcher.target = utils.xy(PitcherStartPos.x, PitcherStartPos.y) fielders.catcher.target = utils.xy(Screen.W * 0.475, Screen.H * 0.92) fielders.left.target = utils.xy(Screen.W * -1, Screen.H * -0.2) fielders.center.target = utils.xy(Center.x, Screen.H * -0.4) fielders.right.target = utils.xy(Screen.W * 2, fielders.left.target.y) end local BatterStartingX = Bases[Home].x - 40 local BatterStartingY = Bases[Home].y - 3 --- @type Runner[] local runners = {} --- @type Runner[] local outRunners = {} ---@return Runner function newRunner() local new = { x = BatterStartingX - 60, y = BatterStartingY + 60, nextBase = RightHandedBattersBox, prevBase = nil, forcedTo = Bases[First], } runners[#runners + 1] = new return new end ---@type Runner | nil local batter = newRunner() local throwMeter = 0 local PitchMeterLimit = 15 --- "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 = utils.distanceBetween(ball.x, ball.y, destX, destY) * 5 end ball.heldBy = nil if customBallScaler then ballSizeAnimator = customBallScaler else -- TODO? Scale based on distance? ballSizeAnimator = gfx.animator.new(flyTimeMs, 9, 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 throwMeter = 0 end local PitchAfterSeconds = 7 -- 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 = 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 local BaseHitbox = 10 --- 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(Bases, function(base) return utils.distanceBetween(x, y, base.x, base.y) < 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(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 runnerIndex integer function outRunner(runnerIndex) outRunners[#outRunners + 1] = runners[runnerIndex] table.remove(runners, runnerIndex) outs = outs + 1 updateForcedRunners() announcer:say("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 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(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(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 > PitchMeterLimit then return (PitchFlyMs / (throwMeter / PitchMeterLimit)) 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 = Bases[Third] elseif playdate.buttonIsPressed(playdate.kButtonUp) then targetBase = Bases[Second] elseif playdate.buttonIsPressed(playdate.kButtonRight) then targetBase = Bases[First] elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then targetBase = Bases[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 = 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) < TagDistance then outRunner(i) didOutRunner = true end end return didOutRunner end function updateNpcFielder(fielder, outedSomeRunner) if offenseMode ~= 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(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(Bases, runner.x, runner.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 local nb = runner.nextBase local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y) if distance < 2 then runner.nextBase = 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 -- Used for tracking whether or not a pitch was a strike local recordedPitchX = nil local balls = 0 local strikes = 0 local StrikeZoneStartX = Center.x - 16 local StrikeZoneEndX = StrikeZoneStartX + 24 local StrikeZoneStartY = Screen.H - 35 function recordStrikePosition() if not recordedPitchX and ball.y > StrikeZoneStartY then recordedPitchX = ball.x end end function nextBatter() playdate.timer.new(2000, function() balls = 0 strikes = 0 batter = newRunner() end) end function walk() -- TODO nextBatter() end function strikeOut() -- TODO nextBatter() end function recordPitch() if recordedPitchX > StrikeZoneStartX and recordedPitchX < StrikeZoneEndX then strikes = strikes + 1 if strikes >= 3 then strikeOut() end else balls = balls + 1 if balls >= 4 then walk() end end end ---@param batDeg number function updateBatting(batDeg, batSpeed) if ball.y < BallOffscreen then ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue() ball.size = SmallestBallRadius -- ballFloatAnimator:currentValue() end local batAngle = math.rad(batDeg) -- TODO: animate bat-flip or something batBase.x = batter and (batter.x + BatOffset.x) or 0 batBase.y = batter and (batter.y + BatOffset.y) or 0 batTip.x = batBase.x + (BatLength * math.sin(batAngle)) batTip.y = batBase.y + (BatLength * math.cos(batAngle)) if batSpeed >= 0 -- > 0 and utils.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() offenseMode = 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 * HitMult) local ballDestY = ball.y + (ballVelY * HitMult) -- Hit! throwBall( ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, gfx.animator.new(2000, 9 + (mult * mult * 0.5), SmallestBallRadius, utils.easingHill) ) fielders.first.target = Bases[First] batter.nextBase = Bases[First] batter.prevBase = Bases[Home] updateForcedRunners() batter.forcedTo = Bases[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) 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 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 < Screen.W + 50 and runner.y < 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 local npcBatDeg = 0 local BaseNpcBatSpeed = 1500 local npcBatSpeed = 1500 function npcBatAngle() if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < Center.x + 15) then npcBatDeg = npcBatDeg + (deltaSeconds * npcBatSpeed) else npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed npcBatDeg = 200 end return npcBatDeg end function npcBatChange() return deltaSeconds * npcBatSpeed end function npcRunningSpeed() if #runners == 0 then return 0 end local touchedBase = isTouchingBase(runners[1].x, runners[1].y) if not touchedBase or touchedBase == Bases[Home] then return 10 end return 0 end function updateGameState() deltaSeconds = playdate.getElapsedTime() or 0 playdate.resetElapsedTime() local crankChange = playdate.getCrankChange() --[[@as number, number]] if ball.heldBy then ball.x = ball.heldBy.x ball.y = ball.heldBy.y else ball.x = ballAnimatorX:currentValue() ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue() end local playerOnOffense, playerOnDefense = playerIsOn(Sides.offense) if playerOnDefense then throwMeter = math.max(0, throwMeter - (deltaSeconds * 150)) throwMeter = throwMeter + math.abs(crankChange) end if offenseMode == Offense.batting then recordStrikePosition() secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then recordPitch() throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true) catcherThrownBall = true end local batSpeed if playerOnOffense then batAngleDeg, batSpeed = (playdate.getCrankPosition() + CrankOffsetDeg) % 360, crankChange else batAngleDeg, batSpeed = npcBatAngle(), npcBatChange() end updateBatting(batAngleDeg, batSpeed) -- TODO: Ensure batter can't be nil, here updateRunner(batter, nil, crankChange) if secondsSincePitchAllowed > PitchAfterSeconds then if playerOnDefense then local throwFly = readThrow() if throwFly and not buttonControlledThrow(fielders.pitcher, throwFly, true) then playerPitch(throwFly) end else pitch(PitchFlyMs, math.random(#Pitches)) end end elseif offenseMode == Offense.running then local appliedSpeed = playerOnOffense and crankChange or npcRunningSpeed() if updateRunning(appliedSpeed) then secondsSinceLastRunnerMove = 0 else secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true) resetFielderPositions() offenseMode = Offense.batting if not batter then batter = newRunner() end end end end for _, fielder in pairs(fielders) do updateFielder(fielder) end walkAwayOutRunners() end local MinimapSizeX, MinimapSizeY = Minimap:getSize() local MinimapPosX, MinimapPosY = Screen.W - MinimapSizeX, Screen.H - MinimapSizeY local FieldHeight = Bases[Home].y - Bases[Second].y local MinimapMultX = 0.75 * MinimapSizeX / Screen.W local MinimapOffsetX = MinimapPosX + 5 local MinimapMultY = 0.70 * MinimapSizeY / FieldHeight local MinimapOffsetY = MinimapPosY - 15 function drawMinimap() Minimap:draw(MinimapPosX, MinimapPosY) gfx.setColor(gfx.kColorBlack) for _, runner in pairs(runners) do local x = (MinimapMultX * runner.x) + MinimapOffsetX local y = (MinimapMultY * runner.y) + MinimapOffsetY gfx.fillRect(x, y, 8, 8) end end ---@param fielder Fielder ---@return boolean isHoldingBall function drawFielderGlove(fielder) local distanceFromBall = utils.distanceBetweenZ(fielder.x, fielder.y, 0, ball.x, ball.y, ballFloatAnimator:currentValue()) local shoulderX, shoulderY = fielder.x + 10, fielder.y + FielderDanceAnimator:currentValue() + 5 if distanceFromBall > 20 then Glove:draw(shoulderX, shoulderY) return false else GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY) return true end 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 < BallOffscreen then offsetX, offsetY = getDrawOffset(Screen.W, 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 local fielderY = fielder.y + fielderDanceHeight gfx.fillRect(fielder.x, fielderY, 14, 25) ballIsHeld = drawFielderGlove(fielder) or ballIsHeld end if offenseMode == 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 PlayerSmile: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() end drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning) drawBallsAndStrikes(300, Screen.H * 0.77, balls, strikes) 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(teams.home.benchPosition) 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()