-- 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 | nil, --- y: number | nil, --- target: XYPair | nil, --- speed: number, --- } import 'announcer.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 = 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 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 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, 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) 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), }, -- Slider { x = gfx.animator.new(PitchFlyMs, PitchStartX - 20, PitchStartX, easingHill), y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear), }, -- Curve ball { x = gfx.animator.new(PitchFlyMs, PitchStartX + 20, PitchStartX, 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 = xy(10, 25) local batBase = xy(Center.x - 34, 215) local batTip = xy(0, 0) local TagDistance = 20 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 --45 local Modes = { batting = {}, running = {}, } local currentMode = Modes.batting -- 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, easingHill) local BallSizeMs = 2000 local ballSizeAnimator = gfx.animator.new(BallSizeMs, 9, SmallestBallRadius, easingHill) local HitMult = 20 local deltaSeconds = 0 local First , Second , Third , Home = 1, 2, 3, 4 ---@type Base[] local Bases = { xy(Screen.W * 0.93, Screen.H * 0.52), xy(Screen.W * 0.47, Screen.H * 0.19), xy(Screen.W * 0.03, Screen.H * 0.52), xy(Screen.W * 0.474, Screen.H * 0.79), } -- Pseudo-base for batter to target local RightHandedBattersBox = xy(Bases[Home].x - 35, Bases[Home].y) ---@type table local NextBaseMap = { [Bases[First]] = Bases[Second], [Bases[Second]] = Bases[Third], [Bases[Third]] = Bases[Home], } 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", 20), left = newFielder("Left", 40), center = newFielder("Center", 40), right = newFielder("Right", 40), } local PitcherStartPos = { x = Screen.W * 0.48, y = Screen.H * 0.40, } --- Resets the target positions of all fielders to their defaults (at their field positions). ---@param fromOffTheField boolean 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 = Center.x fielder.y = Screen.H end end fielders.first.target = xy(Screen.W - 65, Screen.H * 0.48) fielders.second.target = xy(Screen.W * 0.70, Screen.H * 0.30) fielders.shortstop.target = xy(Screen.W * 0.30, Screen.H * 0.30) fielders.third.target = xy(Screen.W * 0.1, Screen.H * 0.48) fielders.pitcher.target = xy(PitcherStartPos.x, PitcherStartPos.y) fielders.catcher.target = xy(Screen.W * 0.475, Screen.H * 0.92) fielders.left.target = xy(Screen.W * -1, Screen.H * -0.2) fielders.center.target = xy(Center.x, Screen.H * -0.4) fielders.right.target = 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() --- "Throws" the ball from its current position to the given destination. function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) if not flyTimeMs then flyTimeMs = 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, 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 local PitchAfterSeconds = 7 -- TODO: Replace with a timer, repeatedly reset instead of setting to 0 local secondsSincePitchAllowed = -5 local catcherThrownBall = false function pitch() catcherThrownBall = false currentMode = Modes.batting local current = Pitches[math.random(#Pitches)] 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 ballAnimatorX:reset() ballAnimatorY:reset() secondsSincePitchAllowed = 0 end local elapsedSec = 0 local crankChange = 0 local acceleratedChange local BaseHitbox = 13 --- Returns the base being touched by the runner at (x,y), or nil, if no base is being touched function isTouchingBase(x, y) for _, base in ipairs(Bases) do if distanceBetween(x, y, base.x, base.y) < BaseHitbox then return base end end return nil end local BallCatchHitbox = 3 function isTouchingBall(x, y) local ballDistance = distanceBetween(x, y, ball.x, ball.y) return ballDistance < BallCatchHitbox end local teams = { home = { score = 0, }, away = { score = 0, }, } local battingTeam = teams.away local outs = 0 local inning = 1 ---@param base Base ---@return Runner | nil function getRunnerTargeting(base) for _, runner in pairs(runners) do if runner.nextBase == base then return runner end end return nil end function updateForcedRunners() local stillForced = true for _, base in ipairs(Bases) do local runnerTargetingBase = getRunnerTargeting(base) if runnerTargetingBase then if stillForced then runnerTargetingBase.forcedTo = base else runnerTargetingBase.forcedTo = nil end else stillForced = false end end end ---@param runnerIndex integer function outRunner(runnerIndex) outs = outs + 1 outRunners[#outRunners + 1] = runners[runnerIndex] table.remove(runners, runnerIndex) updateForcedRunners() announcer:say("YOU'RE OUT!") if outs == 3 then local gameOver = inning == 9 and teams.away.score ~= teams.home.score if not gameOver then fieldersDance() 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 if battingTeam == teams.home then battingTeam = teams.away inning = inning + 1 else battingTeam = teams.home end if gameOver then announcer:say("AND THAT'S THE BALL GAME!") end end) end end -- TODO: Away score 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 = getRunnerTargeting(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 local nearestBase, distance = getNearestOf(Bases, runner.x, runner.y, function(base) return runner.nextBase == base end) if farRunnersBase == nil or farDistance < distance then farRunnersBase = nearestBase farDistance = distance end end return farRunnersBase, farDistance end --- Returns x,y of the throw target ---@return number|nil, number|nil function getNextThrowTarget() -- 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 function tryToThrowOut(thrower) local targetX, targetY = getNextThrowTarget() 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 else throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true) end end end function updateFielders() local touchingBaseCache = 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() tryToThrowOut(fielder) end) else tryToThrowOut(fielder) end end end end end --- Returns true if at least one runner is still moving ---@return boolean function updateRunners(currentRunners) 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 nearestBaseDistance < 5 and runner.prevBase and runner.nextBase == Bases[Home] and nearestBase == Bases[Home] then score(runnerIndex) end if runner.nextBase then local nb = runner.nextBase local x, y, distance = normalizeVector(runner.x, runner.y, nb.x, nb.y) 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 -- 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) runnerMoved = runnerMoved or prevX ~= runner.x or prevY ~= runner.y else runner.nextBase = NextBaseMap[runner.nextBase] runner.forcedTo = nil end end end return runnerMoved end local ResetFieldersAfterSeconds = 2 -- 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 local batAngleDeg function updateBatting() if ball.y < BallOffscreen then ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue() ball.size = SmallestBallRadius -- ballFloatAnimator:currentValue() end batAngleDeg = (playdate.getCrankPosition() + CrankOffsetDeg) % 360 local batAngle = math.rad(batAngleDeg) -- 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 acceleratedChange >= 0 and pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H) then BatCrackSound:play() currentMode = Modes.running local ballAngle = batAngle + math.rad(90) local mult = math.abs(crankChange / 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, 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 = getNearestOf(fielders, ballDestX, ballDestY) chasingFielder.target = { x = ballDestX, y = ballDestY } end end function updateRunning() 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 end end function updateOutRunners() 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 updateGameState() deltaSeconds = playdate.getElapsedTime() or 0 playdate.resetElapsedTime() elapsedSec = elapsedSec + deltaSeconds crankChange, acceleratedChange = 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 if currentMode == Modes.batting then secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true) catcherThrownBall = true end if secondsSincePitchAllowed > PitchAfterSeconds then pitch() end updateBatting() updateRunners({ batter }) elseif currentMode == Modes.running then updateRunning() end updateFielders() updateOutRunners() end -- TODO function drawMinimap() end function playdate.update() playdate.timer.updateTimers() updateGameState() gfx.animation.blinker.updateAll() 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() for _, fielder in pairs(fielders) do gfx.fillRect(fielder.x, fielder.y - fielderDanceHeight, 14, 25) end if currentMode == Modes.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? Change blip speed depending on runner speed? 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? Scale sprites down as y increases PlayerImageBlipper:draw(false, runner.x, runner.y) end end for _, runner in pairs(outRunners) do PlayerFrown:draw(runner.x, runner.y) end 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) gfx.setDrawOffset(0, 0) if offsetX > 0 or offsetY > 0 then drawMinimap() end drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning) announcer:draw(Center.x, 10) end init()