-- 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' -- stylua: ignore end --- @alias Fielder { --- x: number, --- y: number, --- target: XYPair | nil, --- speed: number, --- } --- @alias EasingFunc fun(number, number, number, number): number --- @alias ThrowBall fun(destX: number, destY: number, easingFunc: EasingFunc, flyTimeMs: number | nil, floaty: boolean | nil, customBallScaler: pd_animator | nil) -- stylua: ignore start import 'utils.lua' import 'constants.lua' import 'assets.lua' import 'action-queue.lua' import 'announcer.lua' import 'baserunning.lua' import 'dbg.lua' import 'fielding.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 announcer = Announcer:new() local baserunning = Baserunning.new(announcer) local fielding = Fielding.new() ---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil } local deltaSeconds = 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]], xAnimator = utils.staticAnimator(C.BallOffscreen), yAnimator = utils.staticAnimator(C.BallOffscreen), -- TODO? Replace these with a ballAnimatorZ? -- ...that might lose some of the magic of both. Compromise available? idk sizeAnimator = utils.staticAnimator(C.SmallestBallRadius), floatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill), } ---@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.away local battingTeam = teams.away local outs = 0 local inning = 1 local offenseState = C.Offense.batting 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 BatterHandPos = utils.xy(10, 25) local batBase = utils.xy(C.Center.x - 34, 215) local batTip = utils.xy(0, 0) local batAngleDeg = C.CrankOffsetDeg local PlayerImageBlipper = blipper.new(100, Player, PlayerLowHat) local FielderDanceAnimator = gfx.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1 ---@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((ball.yAnimator: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 ball.sizeAnimator = customBallScaler else -- TODO? Scale based on distance? ball.sizeAnimator = gfx.animator.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill) end ball.yAnimator = gfx.animator.new(flyTimeMs, ball.y, destY, easingFunc) ball.xAnimator = gfx.animator.new(flyTimeMs, ball.x, destX, easingFunc) if floaty then ball.floatAnimator:reset(flyTimeMs) end end ---@param pitchFlyTimeMs number | nil ---@param pitchTypeIndex number | nil local function pitch(pitchFlyTimeMs, pitchTypeIndex) catcherThrownBall = false offenseState = C.Offense.batting local current = Pitches[pitchTypeIndex] ball.xAnimator = current.x ball.yAnimator = current.y or Pitches[1].y -- TODO: This would need to be sanely replaced in throwBall() etc. -- if current.z then -- ball.floatAnimator = current.z -- ball.floatAnimator:reset() -- end if pitchFlyTimeMs then ball.xAnimator:reset(pitchFlyTimeMs) ball.yAnimator:reset(pitchFlyTimeMs) else ball.xAnimator:reset() ball.yAnimator:reset() end secondsSincePitchAllowed = 0 end ---@param runner integer | Runner ---@param message string | nil local function outRunner(runner, message) baserunning:outRunner(runner, message) if baserunning.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 fielding:benchTo(currentlyFieldingTeam.benchPosition) announcer:say("SWITCHING SIDES...") 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 fielding:resetFielderPositions() if battingTeam == teams.home then inning = inning + 1 end end end) end ---@param scoredRunCount number local function score(scoredRunCount) battingTeam.score = battingTeam.score + scoredRunCount 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 fielding:playerThrowTo(targetBase, throwBall, throwFlyMs) secondsSinceLastRunnerMove = 0 offenseState = C.Offense.running return true end local function nextBatter() baserunning.batter = nil playdate.timer.new(2000, function() pitchTracker:reset() if not baserunning.batter then baserunning.batter = baserunning:newRunner() end end) end local function walk() announcer:say("Walk!") baserunning.batter.nextBase = C.Bases[C.First] baserunning.batter.prevBase = C.Bases[C.Home] offenseState = C.Offense.walking baserunning.batter = nil baserunning:updateForcedRunners() nextBatter() end local function strikeOut() local outBatter = baserunning.batter baserunning.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 = baserunning.batter and (baserunning.batter.x + BatterHandPos.x) or 0 batBase.y = baserunning.batter and (baserunning.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() offenseState = 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) baserunning.batter.nextBase = C.Bases[C.First] baserunning.batter.prevBase = C.Bases[C.Home] baserunning:updateForcedRunners() baserunning.batter.forcedTo = C.Bases[C.First] baserunning.batter = nil -- Demote batter to a mere runner fielding:haveSomeoneChase(ballDestX, ballDestY) end end ---@param appliedSpeed number ---@return boolean someRunnerMoved local function updateNonBatterRunners(appliedSpeed, forcedOnly) local runnerMoved, runnersScored = baserunning:updateRunning(appliedSpeed, forcedOnly, deltaSeconds) if runnersScored ~= 0 then score(runnersScored) end return runnerMoved 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 = ball.xAnimator:currentValue() ball.z = ball.floatAnimator:currentValue() ball.y = ball.yAnimator:currentValue() + ball.z ball.size = ball.sizeAnimator: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 offenseState == 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 = fielding.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) -- Walk batter to the plate -- TODO: Ensure batter can't be nil, here baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds) 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 offenseState == C.Offense.running then local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(baserunning.runners) if updateNonBatterRunners(appliedSpeed) then secondsSinceLastRunnerMove = 0 else secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) fielding:resetFielderPositions() offenseState = C.Offense.batting if not baserunning.batter then baserunning.batter = baserunning:newRunner() end end end elseif offenseState == C.Offense.walking then if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then offenseState = C.Offense.batting end end local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds) if playerOnDefense then local throwFly = readThrow() if throwFly then buttonControlledThrow(throwFly) end end if fielderHoldingBall then local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) if playerOnOffense then npc.fielderAction(fielding, baserunning, offenseState, fielderHoldingBall, outedSomeRunner, ball, throwBall) end end baserunning:walkAwayOutRunners(deltaSeconds) 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(fielding.fielders) do ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld end if offenseState == 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(baserunning.runners) do if runner == baserunning.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(baserunning.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(baserunning.runners, fielding.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")) fielding: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()