541 lines
18 KiB
Lua
541 lines
18 KiB
Lua
-- 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 EasingFunc fun(number, number, number, number): number
|
|
|
|
--- @alias LaunchBall 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 'draw/player.lua'
|
|
import 'draw/overlay.lua'
|
|
import 'draw/fielder.lua'
|
|
|
|
import 'action-queue.lua'
|
|
import 'announcer.lua'
|
|
import 'ball.lua'
|
|
import 'baserunning.lua'
|
|
import 'dbg.lua'
|
|
import 'fielding.lua'
|
|
import 'graphics.lua'
|
|
import 'npc.lua'
|
|
-- stylua: ignore end
|
|
|
|
-- selene: allow(shadowing)
|
|
local gfx <const>, C <const> = playdate.graphics, C
|
|
|
|
local announcer = Announcer.new()
|
|
local fielding = Fielding.new()
|
|
-- TODO: Find a way to get baserunning and npc instantiated closer to the top, here.
|
|
-- Currently difficult because they depend on nextHalfInning/each other.
|
|
|
|
------------------
|
|
-- GLOBAL STATE --
|
|
------------------
|
|
|
|
local deltaSeconds = 0
|
|
local ball = Ball.new(gfx.animator)
|
|
|
|
local batBase <const> = utils.xy(C.Center.x - 34, 215)
|
|
local batTip <const> = utils.xy(0, 0)
|
|
local batAngleDeg = C.CrankOffsetDeg
|
|
|
|
local catcherThrownBall = false
|
|
|
|
---@alias Team { score: number, benchPosition: XyPair }
|
|
---@type table<string, Team>
|
|
local teams <const> = {
|
|
home = {
|
|
score = 0,
|
|
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
|
|
},
|
|
away = {
|
|
score = 0,
|
|
benchPosition = utils.xy(-10, C.Center.y),
|
|
},
|
|
}
|
|
|
|
local inning = 1
|
|
|
|
local battingTeam = teams.away
|
|
local offenseState = C.Offense.batting
|
|
|
|
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
|
|
local secondsSinceLastRunnerMove = 0
|
|
local secondsSincePitchAllowed = 0
|
|
|
|
-- These are only sort-of global state. They are purely graphical,
|
|
-- but they need to be kept in sync with the rest of the globals.
|
|
local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
|
|
local battingTeamSprites = AwayTeamSprites
|
|
local fieldingTeamSprites = HomeTeamSprites
|
|
|
|
-------------------------
|
|
-- END OF GLOBAL STATE --
|
|
-------------------------
|
|
|
|
local UserTeam <const> = teams.away
|
|
|
|
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
|
|
---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
|
|
|
|
---@type Pitch[]
|
|
local Pitches <const> = {
|
|
-- 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 userIsOnSide, boolean userIsOnOtherSide
|
|
local function userIsOn(side)
|
|
if UserTeam == nil then
|
|
-- Both teams are NPC-driven
|
|
return false, false
|
|
end
|
|
local ret
|
|
if UserTeam == battingTeam then
|
|
ret = side == C.Sides.offense
|
|
else
|
|
ret = side == C.Sides.defense
|
|
end
|
|
return ret, not ret
|
|
end
|
|
|
|
---@type LaunchBall
|
|
local function launchBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
|
|
throwMeter:reset()
|
|
ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
|
|
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 launchBall() 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
|
|
|
|
local function nextHalfInning()
|
|
pitchTracker:reset()
|
|
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
|
|
fielding:celebrate()
|
|
secondsSinceLastRunnerMove = -7
|
|
fielding:benchTo(currentlyFieldingTeam.benchPosition)
|
|
announcer:say("SWITCHING SIDES...")
|
|
end
|
|
|
|
if gameOver then
|
|
announcer:say("AND THAT'S THE BALL GAME!")
|
|
else
|
|
fielding:resetFielderPositions()
|
|
if battingTeam == teams.home then
|
|
inning = inning + 1
|
|
end
|
|
battingTeam = currentlyFieldingTeam
|
|
playdate.timer.new(2000, function()
|
|
if battingTeam == teams.home then
|
|
battingTeamSprites = HomeTeamSprites
|
|
runnerBlipper = HomeTeamBlipper
|
|
fieldingTeamSprites = AwayTeamSprites
|
|
else
|
|
battingTeamSprites = AwayTeamSprites
|
|
fieldingTeamSprites = HomeTeamSprites
|
|
runnerBlipper = AwayTeamBlipper
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
local baserunning = Baserunning.new(announcer, nextHalfInning)
|
|
local npc = Npc.new(baserunning.runners, fielding.fielders)
|
|
|
|
---@param scoredRunCount number
|
|
local function score(scoredRunCount)
|
|
battingTeam.score = battingTeam.score + scoredRunCount
|
|
announcer:say("SCORE!")
|
|
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:reset()
|
|
|
|
fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
|
|
secondsSinceLastRunnerMove = 0
|
|
offenseState = C.Offense.running
|
|
|
|
return true
|
|
end
|
|
|
|
local function nextBatter()
|
|
secondsSincePitchAllowed = -3
|
|
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
|
|
baserunning: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 + C.BatterHandPos.x) or 0
|
|
batBase.y = baserunning.batter and (baserunning.batter.y + C.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
|
|
-- Hit!
|
|
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)
|
|
pitchTracker:reset()
|
|
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
|
|
launchBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler)
|
|
-- TODO? A dramatic eye-level view on a home-run could be sick.
|
|
|
|
if utils.isFoulBall(ballDestX, ballDestY) then
|
|
announcer:say("Foul ball!")
|
|
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
|
|
-- TODO: Have a fielder chase for the fly-out
|
|
return
|
|
end
|
|
|
|
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:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
|
|
if runnersScored ~= 0 then
|
|
score(runnersScored)
|
|
end
|
|
return runnerMoved
|
|
end
|
|
|
|
local function userPitch(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 userOnOffense, userOnDefense = userIsOn(C.Sides.offense)
|
|
|
|
if userOnDefense then
|
|
throwMeter:applyCharge(deltaSeconds, crankLimited)
|
|
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.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then
|
|
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
|
|
end
|
|
|
|
if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then
|
|
local outcome = pitchTracker:updatePitchCounts()
|
|
if outcome == PitchOutcomes.StrikeOut then
|
|
strikeOut()
|
|
elseif outcome == PitchOutcomes.Walk then
|
|
walk()
|
|
end
|
|
-- Catcher has the ball. Throw it back to the pitcher
|
|
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
|
|
catcherThrownBall = true
|
|
end
|
|
|
|
local batSpeed
|
|
if userOnOffense 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 userOnDefense then
|
|
local throwFly = throwMeter:readThrow()
|
|
if throwFly and not buttonControlledThrow(throwFly, true) then
|
|
userPitch(throwFly)
|
|
end
|
|
else
|
|
pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches))
|
|
end
|
|
end
|
|
elseif offenseState == C.Offense.running then
|
|
local appliedSpeed = userOnOffense and crankLimited or npc:runningSpeed(ball)
|
|
if updateNonBatterRunners(appliedSpeed) then
|
|
secondsSinceLastRunnerMove = 0
|
|
else
|
|
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
|
|
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
|
|
-- End of play. Throw the ball back to the pitcher
|
|
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
|
|
fielding:resetFielderPositions()
|
|
offenseState = C.Offense.batting
|
|
-- TODO: Remove, or replace with nextBatter()
|
|
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 userOnDefense then
|
|
local throwFly = throwMeter:readThrow()
|
|
if throwFly then
|
|
buttonControlledThrow(throwFly)
|
|
end
|
|
end
|
|
if fielderHoldingBall then
|
|
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
|
|
if not userOnDefense then
|
|
npc:fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, launchBall)
|
|
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 ballIsHeld = fielding:drawFielders(fieldingTeamSprites, ball)
|
|
|
|
if offenseState == C.Offense.batting then
|
|
gfx.setLineWidth(5)
|
|
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
|
|
end
|
|
|
|
local playerHeightOffset = 10
|
|
-- 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
|
|
battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
|
|
else
|
|
battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
|
|
end
|
|
else
|
|
-- TODO? Change blip speed depending on runner speed?
|
|
runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
|
|
end
|
|
end
|
|
|
|
for _, runner in pairs(baserunning.outRunners) do
|
|
battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset)
|
|
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, baserunning.outs, battingTeam, inning)
|
|
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
|
|
announcer:draw(C.Center.x, 10)
|
|
|
|
if playdate.isCrankDocked() then
|
|
playdate.ui.crankIndicator:draw()
|
|
end
|
|
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()
|
|
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
|
|
end)
|
|
BootTune:play()
|
|
BootTune:setFinishCallback(function()
|
|
TinnyBackground:play()
|
|
end)
|
|
end
|
|
|
|
init()
|