-- 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 'action-queue.lua' import 'announcer.lua' import 'dbg.lua' import 'field.lua' import 'graphics.lua' import 'npc.lua' import 'draw/overlay.lua' import 'draw/fielder.lua' -- stylua: ignore end -- selene: allow(shadowing) local gfx , C = playdate.graphics, C local PlayerImageBlipper = blipper.new(100, Player, PlayerLowHat) local FielderDanceAnimator = gfx.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1 ---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil } local ballAnimatorX = utils.staticAnimator(C.BallOffscreen) local ballAnimatorY = utils.staticAnimator(C.BallOffscreen) local ballSizeAnimator = utils.staticAnimator(C.SmallestBallRadius) -- TODO? Replace this AND ballSizeAnimator with a ballAnimatorZ? -- ...that might lose some of the magic of both. Compromise available? idk local ballFloatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill) local deltaSeconds = 0 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 --- @type Runner[] local runners = {} --- @type Runner[] local outRunners = {} ---@type Runner | nil local batter = utils.newRunner(runners) local throwMeter = 0 -- TODO: Replace with a timer, repeatedly reset, instead of setting to 0 local secondsSinceLastRunnerMove = 0 -- TODO: Replace with a timer, repeatedly reset instead of setting to 0 local secondsSincePitchAllowed = -5 local catcherThrownBall = false local batAngleDeg = C.CrankOffsetDeg ---@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), }, } ---@return boolean playerIsOnSide, boolean playerIsOnOtherSide local 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 --- "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 local 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 ---@param pitchFlyTimeMs number | nil ---@param pitchTypeIndex number | nil local 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 local function updateForcedRunners() local stillForced = true for _, base in ipairs(C.Bases) do local runnerTargetingBase = utils.getRunnerWithNextBase(runners, base) if runnerTargetingBase then if stillForced then runnerTargetingBase.forcedTo = base else runnerTargetingBase.forcedTo = nil end else stillForced = false end end end ---@param runner integer | Runner local 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 return end 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 FielderDanceAnimator:reset(C.DanceBounceMs) secondsSinceLastRunnerMove = -7 Field:benchTo(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 Field:resetFielderPositions() if battingTeam == teams.home then inning = inning + 1 end end end) end ---@param runnerIndex number local function score(runnerIndex) outRunners[#outRunners + 1] = runners[runnerIndex] table.remove(runners, runnerIndex) battingTeam.score = battingTeam.score + 1 announcer:say("SCORE!") end local function readThrow() if throwMeter > C.ThrowMeterMax then return (C.PitchFlyMs / (throwMeter / C.ThrowMeterMax)) end return nil end ---@param throwFlyMs number ---@return boolean didThrow local function buttonControlledThrow(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 -- Power for this throw has already been determined throwMeter = 0 Field:playerThrowTo(targetBase, throwBall, throwFlyMs) secondsSinceLastRunnerMove = 0 offenseMode = C.Offense.running return true end local function outEligibleRunners(fielder) local touchedBase = utils.isTouchingBase(fielder.x, fielder.y) local didOutRunner = false for i, runner in pairs(runners) do local runnerOnBase = utils.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 local function npcFielderAction(fielder, outedSomeRunner) if offenseMode ~= C.Offense.running then return end if outedSomeRunner then -- Delay a little before the next play playdate.timer.new(750, function() npc.tryToMakeAPlay(fielder, runners, ball, throwBall) end) else npc.tryToMakeAPlay(fielder, runners, ball, throwBall) 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 local 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 local function nextBatter() batter = nil playdate.timer.new(2000, function() pitchTracker:reset() if not batter then batter = utils.newRunner(runners) end end) end local function walk() announcer:say("Walk!") batter.nextBase = C.Bases[C.First] batter.prevBase = C.Bases[C.Home] offenseMode = C.Offense.walking batter = nil updateForcedRunners() nextBatter() end local function strikeOut() local outBatter = batter batter = nil outRunner(outBatter --[[@as Runner]], "Strike out!") nextBatter() end ---@param batDeg number local function updateBatting(batDeg, batSpeed) 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 BatCrackReverb: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) 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 Field:haveSomeoneChase(ballDestX, 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 local function updateRunning(appliedSpeed, forcedOnly) 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 local 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 local 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 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 ball.size = C.SmallestBallRadius else ball.x = ballAnimatorX:currentValue() ball.z = ballFloatAnimator:currentValue() ball.y = ballAnimatorY:currentValue() + ball.z ball.size = ballSizeAnimator:currentValue() end local playerOnOffense, playerOnDefense = playerIsOn(C.Sides.offense) if playerOnDefense then throwMeter = math.max(0, throwMeter - (deltaSeconds * C.ThrowMeterDrainPerSec)) throwMeter = throwMeter + math.abs(crankLimited * C.PitchPower) 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 = Field.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(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 > C.ResetFieldersAfterSeconds then throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) Field:resetFielderPositions() offenseMode = C.Offense.batting if not batter then batter = utils.newRunner(runners) end end end elseif offenseMode == C.Offense.walking then updateForcedRunners() if not updateRunning(C.WalkedRunnerSpeed, true) then offenseMode = C.Offense.batting end end local fielderHoldingBall = Field:updateFielderPositions(ball, deltaSeconds) if playerOnDefense then local throwFly = readThrow() if throwFly then buttonControlledThrow(throwFly) end end if fielderHoldingBall then local outedSomeRunner = outEligibleRunners(fielderHoldingBall) if playerOnOffense then npcFielderAction(fielderHoldingBall, outedSomeRunner) end end walkAwayOutRunners() actionQueue:runWaiting(deltaSeconds) end function playdate.update() playdate.timer.updateTimers() gfx.animation.blinker.updateAll() updateGameState() gfx.clear() gfx.setColor(gfx.kColorBlack) local offsetX, offsetY = getDrawOffset(ball.x, ball.y) gfx.setDrawOffset(offsetX, offsetY) GrassBackground:draw(-400, -240) local fielderDanceHeight = FielderDanceAnimator:currentValue() local ballIsHeld = false for _, fielder in pairs(Field.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, Field.fielders) 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 local function init() playdate.display.setRefreshRate(50) gfx.setBackgroundColor(gfx.kColorWhite) playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) Field: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()