Convert main into a Game object.

Much BATTER encapsulation of its dependencies and mutable state. Only the team scores are still global and mutable, but that shouldn't be too hard to fix.
This commit is contained in:
Sage Vaillancourt 2025-02-15 17:38:56 -05:00
parent bb95ef5a63
commit 51855e13cf
6 changed files with 317 additions and 288 deletions

View File

@ -58,6 +58,7 @@ end
---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
throwMeter:reset()
self.heldBy = nil
if not flyTimeMs then

View File

@ -151,9 +151,9 @@ end
-- TODO? Start moving target fielders close sooner?
---@param field Fielding
---@param targetBase Base
---@param launchBall LaunchBall
---@param ball { launch: LaunchBall }
---@param throwFlyMs number
local function userThrowToCoroutine(field, targetBase, launchBall, throwFlyMs)
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
while true do
if field.fielderHoldingBall == nil then
coroutine.yield()
@ -163,7 +163,7 @@ local function userThrowToCoroutine(field, targetBase, launchBall, throwFlyMs)
end)
closestFielder.target = targetBase
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
Fielding.markIneligible(field.fielderHoldingBall)
return
@ -174,12 +174,12 @@ end
--- Buffer in a fielder throw action.
---@param self table
---@param targetBase Base
---@param launchBall LaunchBall
---@param ball { launch: LaunchBall }
---@param throwFlyMs number
function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
function Fielding:userThrowTo(targetBase, ball, throwFlyMs)
local maxTryTimeMs = 5000
actionQueue:upsert("userThrowTo", maxTryTimeMs, function()
userThrowToCoroutine(self, targetBase, launchBall, throwFlyMs)
userThrowToCoroutine(self, targetBase, ball, throwFlyMs)
end)
end

View File

@ -20,6 +20,8 @@ function getDrawOffset(ballX, ballY)
return offsetX * 1.3, offsetY * 1.5
end
---@class Blipper
---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number)
-- selene: allow(unscoped_variables)
blipper = {}
@ -39,9 +41,3 @@ function blipper.new(msInterval, smiling, lowHat)
end,
}
end
--selene: allow(unscoped_variables)
HomeTeamBlipper = blipper.new(100, HomeTeamSprites.smiling, HomeTeamSprites.lowHat)
--selene: allow(unscoped_variables)
AwayTeamBlipper = blipper.new(100, AwayTeamSprites.smiling, AwayTeamSprites.lowHat)

View File

@ -1,9 +1,8 @@
-- selene: allow(unscoped_variables)
---@class MainMenu
MainMenu = {
mainGameUpdateFunction = nil,
---@type fun(settings: Settings)
mainGameInitFunction = nil,
---@type { new: fun(settings: Settings): { update: fun(self) } }
next = nil,
}
-- selene: allow(shadowing)
local gfx = playdate.graphics
@ -12,24 +11,25 @@ local gfx = playdate.graphics
local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft")
--- Take control of playdate.update
---@param config MainMenu Function that controls the main gameplay loop.
--- Will replace playdate.update when the menu is done.
function MainMenu.start(config)
MainMenu.mainGameUpdateFunction = config.mainGameUpdateFunction
MainMenu.mainGameInitFunction = config.mainGameInitFunction
--- Will replace playdate.update when the menu is done.
---@param next { new: fun(settings: Settings): { update: fun(self) } }
function MainMenu.start(next)
MainMenu.next = next
playdate.update = MainMenu.update
end
local inningCountSelection = 3
local function startGame()
MainMenu.mainGameInitFunction({
local next = MainMenu.next.new({
finalInning = inningCountSelection,
homeTeamSprites = HomeTeamSprites,
awayTeamSprites = AwayTeamSprites,
})
playdate.resetElapsedTime()
playdate.update = MainMenu.mainGameUpdateFunction
playdate.update = function()
next:update()
end
end
local function pausingEaser(baseEaser)

View File

@ -11,6 +11,7 @@ import 'CoreLibs/ui.lua'
--- @alias EasingFunc fun(number, number, number, number): number
--- @alias LaunchBall fun(
--- self: self,
--- destX: number,
--- destY: number,
--- easingFunc: EasingFunc,
@ -44,34 +45,6 @@ import 'npc.lua'
-- 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 --
------------------
--- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
---@class Settings
local settings = {
finalInning = 3,
---@type SpriteCollection
awayTeamSprites = nil,
---@type SpriteCollection
homeTeamSprites = nil,
}
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> = {
@ -85,67 +58,152 @@ local teams <const> = {
},
}
local inning = 1
local battingTeam = teams.away
local offenseState = C.Offense.batting
--- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
---@class Settings
---@field finalInning number
---@field userTeam Team | nil
---@field awayTeamSprites SpriteCollection
---@field homeTeamSprites SpriteCollection
---@class MutableState
---@field deltaSeconds number
---@field ball Ball
---@field battingTeam Team
---@field catcherThrownBall boolean
---@field offenseState OffenseState
---@field inning number
---@field batBase XyPair
---@field batTip XyPair
---@field batAngleDeg number
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
local secondsSinceLastRunnerMove = 0
local secondsSincePitchAllowed = 0
---@field secondsSinceLastRunnerMove number
---@field secondsSincePitchAllowed number
-- 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
local battingTeamSprites
local fieldingTeamSprites
---@field runnerBlipper Blipper
---@field battingTeamSprites SpriteCollection
---@field fieldingTeamSprites SpriteCollection
-------------------------
-- END OF GLOBAL STATE --
-------------------------
---@class Game
---@field private settings Settings
---@field private announcer Announcer
---@field private fielding Fielding
---@field private baserunning Baserunning
---@field private npc Npc
---@field private homeTeamBlipper Blipper
---@field private awayTeamBlipper Blipper
---@field private state MutableState
-- selene: allow(unscoped_variables)
Game = {}
local UserTeam <const> = teams.away
---@param settings Settings
---@param announcer Announcer | nil
---@param fielding Fielding | nil
---@param baserunning Baserunning | nil
---@param npc Npc | nil
---@param state MutableState | nil
---@return Game
function Game.new(settings, announcer, fielding, baserunning, npc, state)
announcer = announcer or Announcer.new()
fielding = fielding or Fielding.new()
settings.userTeam = teams.away
local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat)
local battingTeam = teams.away
local runnerBlipper = battingTeam == teams.away and awayTeamBlipper or homeTeamBlipper
local ball = Ball.new(gfx.animator)
local o = setmetatable({
settings = settings,
announcer = announcer,
fielding = fielding,
state = state or {
batBase = utils.xy(C.Center.x - 34, 215),
batTip = utils.xy(0, 0),
batAngleDeg = C.CrankOffsetDeg,
deltaSeconds = 0,
ball = ball,
battingTeam = battingTeam,
offenseState = C.Offense.batting,
inning = 1,
catcherThrownBall = false,
secondsSinceLastRunnerMove = 0,
secondsSincePitchAllowed = 0,
battingTeamSprites = settings.awayTeamSprites,
fieldingTeamSprites = settings.homeTeamSprites,
homeTeamBlipper = homeTeamBlipper,
awayTeamBlipper = awayTeamBlipper,
runnerBlipper = runnerBlipper,
},
}, { __index = Game })
o.baserunning = baserunning or Baserunning.new(announcer, function()
o:nextHalfInning()
end)
o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)
o.fielding:resetFielderPositions(teams.home.benchPosition)
playdate.timer.new(2000, function()
ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
return o
end
---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
---@alias Pitch fun(ball: Ball): { 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),
},
function()
return {
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),
}
end,
-- 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),
},
function()
return {
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),
}
end,
-- 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),
},
function()
return {
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),
}
end,
-- 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),
},
function(ball)
return {
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),
}
end,
}
---@return boolean userIsOnSide, boolean userIsOnOtherSide
local function userIsOn(side)
if UserTeam == nil then
function Game:userIsOn(side)
if self.settings.userTeam == nil then
-- Both teams are NPC-driven
return false, false
end
local ret
if UserTeam == battingTeam then
if self.settings.userTeam == self.state.battingTeam then
ret = side == C.Sides.offense
else
ret = side == C.Sides.defense
@ -153,86 +211,77 @@ local function userIsOn(side)
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)
Fielding.markIneligible(fielding.fielders.pitcher)
ball.heldBy = nil
catcherThrownBall = false
offenseState = C.Offense.batting
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex)
Fielding.markIneligible(self.fielding.fielders.pitcher)
self.state.ball.heldBy = nil
self.state.catcherThrownBall = false
self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex]
ball.xAnimator = current.x
ball.yAnimator = current.y or Pitches[1].y
local current = Pitches[pitchTypeIndex](self.state.ball)
self.state.ball.xAnimator = current.x
self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y
-- TODO: This would need to be sanely replaced in launchBall() etc.
-- TODO: This would need to be sanely replaced in ball:launch() etc.
-- if current.z then
-- ball.floatAnimator = current.z
-- ball.floatAnimator:reset()
-- end
if pitchFlyTimeMs then
ball.xAnimator:reset(pitchFlyTimeMs)
ball.yAnimator:reset(pitchFlyTimeMs)
self.state.ball.xAnimator:reset(pitchFlyTimeMs)
self.state.ball.yAnimator:reset(pitchFlyTimeMs)
else
ball.xAnimator:reset()
ball.yAnimator:reset()
self.state.ball.xAnimator:reset()
self.state.ball.yAnimator:reset()
end
secondsSincePitchAllowed = 0
self.state.secondsSincePitchAllowed = 0
end
local function nextHalfInning()
function Game:nextHalfInning()
pitchTracker:reset()
local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home
local gameOver = inning == settings.finalInning and teams.away.score ~= teams.home.score
local currentlyFieldingTeam = self.state.battingTeam == teams.home and teams.away or teams.home
local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score
if not gameOver then
fielding:celebrate()
secondsSinceLastRunnerMove = -7
fielding:benchTo(currentlyFieldingTeam.benchPosition)
announcer:say("SWITCHING SIDES...")
self.fielding:celebrate()
self.state.secondsSinceLastRunnerMove = -7
self.fielding:benchTo(currentlyFieldingTeam.benchPosition)
self.announcer:say("SWITCHING SIDES...")
end
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
self.announcer:say("AND THAT'S THE BALL GAME!")
else
fielding:resetFielderPositions()
if battingTeam == teams.home then
inning = inning + 1
self.fielding:resetFielderPositions()
if self.state.battingTeam == teams.home then
self.state.inning = self.state.inning + 1
end
battingTeam = currentlyFieldingTeam
self.state.battingTeam = currentlyFieldingTeam
playdate.timer.new(2000, function()
if battingTeam == teams.home then
battingTeamSprites = settings.homeTeamSprites
runnerBlipper = HomeTeamBlipper
fieldingTeamSprites = settings.awayTeamSprites
if self.state.battingTeam == teams.home then
self.state.battingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.homeTeamBlipper
self.state.fieldingTeamSprites = self.settings.awayTeamSprites
else
battingTeamSprites = settings.awayTeamSprites
fieldingTeamSprites = settings.homeTeamSprites
runnerBlipper = AwayTeamBlipper
self.state.battingTeamSprites = self.settings.awayTeamSprites
self.state.fieldingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.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!")
function Game:score(scoredRunCount)
self.state.battingTeam.score = self.state.battingTeam.score + scoredRunCount
self.announcer:say("SCORE!")
end
---@param throwFlyMs number
---@return boolean didThrow
local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third]
@ -246,62 +295,67 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
return false
end
-- Power for this throw has already been determined
throwMeter:reset()
fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
secondsSinceLastRunnerMove = 0
offenseState = C.Offense.running
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
self.state.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running
return true
end
local function nextBatter()
secondsSincePitchAllowed = -3
baserunning.batter = nil
function Game:nextBatter()
self.state.secondsSincePitchAllowed = -3
self.baserunning.batter = nil
playdate.timer.new(2000, function()
pitchTracker:reset()
if not baserunning.batter then
baserunning:pushNewBatter()
if not self.baserunning.batter then
self.baserunning:pushNewBatter()
end
end)
end
local function walk()
announcer:say("Walk!")
-- TODO? Use baserunning:convertBatterToRunner()
baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home]
offenseState = C.Offense.walking
baserunning.batter = nil
baserunning:updateForcedRunners()
nextBatter()
function Game:walk()
self.announcer:say("Walk!")
-- TODO? Use self.baserunning:convertBatterToRunner()
self.baserunning.batter.nextBase = C.Bases[C.First]
self.baserunning.batter.prevBase = C.Bases[C.Home]
self.state.offenseState = C.Offense.walking
self.baserunning.batter = nil
self.baserunning:updateForcedRunners()
self:nextBatter()
end
local function strikeOut()
local outBatter = baserunning.batter
baserunning.batter = nil
baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
nextBatter()
function Game:strikeOut()
local outBatter = self.baserunning.batter
self.baserunning.batter = nil
self.baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
self:nextBatter()
end
---@param batDeg number
local function updateBatting(batDeg, batSpeed)
function Game: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))
self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0
self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0
self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
self.state.batTip.y = self.state.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
and utils.pointDirectlyUnderLine(
self.state.ball.x,
self.state.ball.y,
self.state.batBase.x,
self.state.batBase.y,
self.state.batTip.x,
self.state.batTip.y,
C.Screen.H
)
and self.state.ball.y < 232
then
-- Hit!
BatCrackReverb:play()
offenseState = C.Offense.running
self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15)
@ -311,52 +365,54 @@ local function updateBatting(batDeg, batSpeed)
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
local ballDestX = ball.x + (ballVelX * C.BattingPower)
local ballDestY = ball.y + (ballVelY * C.BattingPower)
local ballDestX = self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = self.state.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)
self.state.ball:launch(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!")
self.announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out
return
end
baserunning:convertBatterToRunner()
self.baserunning:convertBatterToRunner()
fielding:haveSomeoneChase(ballDestX, ballDestY)
self.fielding:haveSomeoneChase(ballDestX, ballDestY)
end
end
---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved
local function updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored =
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
if runnersScored ~= 0 then
score(runnersScored)
self: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)
---@param throwFly number
function Game:userPitch(throwFly)
local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
if not aPressed and not bPressed then
self:pitch(throwFly, 1)
elseif aPressed and not bPressed then
self:pitch(throwFly, 2)
elseif not aPressed and bPressed then
self:pitch(throwFly, 3)
elseif aPressed and bPressed then
self:pitch(throwFly, 4)
end
end
local function updateGameState()
deltaSeconds = playdate.getElapsedTime() or 0
function Game:updateGameState()
self.state.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)
@ -364,115 +420,117 @@ local function updateGameState()
crankLimited = crankLimited * -1
end
ball:updatePosition()
self.state.ball:updatePosition()
local userOnOffense, userOnDefense = userIsOn(C.Sides.offense)
local userOnOffense, userOnDefense = self:userIsOn(C.Sides.offense)
if userOnDefense then
throwMeter:applyCharge(deltaSeconds, crankLimited)
throwMeter:applyCharge(self.state.deltaSeconds, crankLimited)
end
if offenseState == C.Offense.batting then
if ball.y < C.StrikeZoneStartY then
if self.state.offenseState == C.Offense.batting then
if self.state.ball.y < C.StrikeZoneStartY then
pitchTracker.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then
pitchTracker.recordedPitchX = ball.x
pitchTracker.recordedPitchX = self.state.ball.x
end
local pitcher = fielding.fielders.pitcher
local pitcher = self.fielding.fielders.pitcher
if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
self.state.secondsSincePitchAllowed = self.state.secondsSincePitchAllowed + self.state.deltaSeconds
end
if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then
if self.state.secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not self.state.catcherThrownBall then
local outcome = pitchTracker:updatePitchCounts()
if outcome == PitchOutcomes.StrikeOut then
strikeOut()
self:strikeOut()
elseif outcome == PitchOutcomes.Walk then
walk()
self:walk()
end
-- Catcher has the ball. Throw it back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
catcherThrownBall = true
self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
self.state.catcherThrownBall = true
end
local batSpeed
if userOnOffense then
batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
batSpeed = crankLimited
else
batAngleDeg = npc:updateBatAngle(ball, catcherThrownBall, deltaSeconds)
batSpeed = npc:batSpeed() * deltaSeconds
self.state.batAngleDeg =
self.npc:updateBatAngle(self.state.ball, self.state.catcherThrownBall, self.state.deltaSeconds)
batSpeed = self.npc:batSpeed() * self.state.deltaSeconds
end
updateBatting(batAngleDeg, batSpeed)
self:updateBatting(self.state.batAngleDeg, batSpeed)
-- Walk batter to the plate
-- TODO: Ensure batter can't be nil, here
baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds)
self.baserunning:updateRunner(self.baserunning.batter, nil, crankLimited, self.state.deltaSeconds)
if secondsSincePitchAllowed > C.PitchAfterSeconds then
if self.state.secondsSincePitchAllowed > C.PitchAfterSeconds then
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly and not buttonControlledThrow(throwFly, true) then
userPitch(throwFly)
if throwFly and not self:buttonControlledThrow(throwFly, true) then
self:userPitch(throwFly)
end
else
pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches))
self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), math.random(#Pitches))
end
end
elseif offenseState == C.Offense.running then
elseif self.state.offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited
or function(runner)
return npc:runningSpeed(runner, ball)
return self.npc:runningSpeed(runner, self.state.ball)
end
if updateNonBatterRunners(appliedSpeed) then
secondsSinceLastRunnerMove = 0
if self:updateNonBatterRunners(appliedSpeed) then
self.state.secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds
if self.state.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:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
fielding:resetFielderPositions()
offenseState = C.Offense.batting
self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
self.fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
self.fielding:resetFielderPositions()
self.state.offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter()
if not baserunning.batter then
baserunning:pushNewBatter()
if not self.baserunning.batter then
self.baserunning:pushNewBatter()
end
end
end
elseif offenseState == C.Offense.walking then
if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
offenseState = C.Offense.batting
elseif self.state.offenseState == C.Offense.walking then
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
self.state.offenseState = C.Offense.batting
end
end
local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds)
local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if userOnDefense then
local throwFly = throwMeter:readThrow()
if throwFly then
buttonControlledThrow(throwFly)
self:buttonControlledThrow(throwFly)
end
end
if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
if not userOnDefense and offenseState == C.Offense.running then
npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall)
local outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
if not userOnDefense and self.state.offenseState == C.Offense.running then
self.npc:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
end
end
baserunning:walkAwayOutRunners(deltaSeconds)
actionQueue:runWaiting(deltaSeconds)
self.baserunning:walkAwayOutRunners(self.state.deltaSeconds)
actionQueue:runWaiting(self.state.deltaSeconds)
end
-- TODO: Swappable update() for main menu, etc.
function mainGameUpdate()
function Game:update()
playdate.timer.updateTimers()
gfx.animation.blinker.updateAll()
updateGameState()
self:updateGameState()
local ball = self.state.ball
gfx.clear()
gfx.setColor(gfx.kColorBlack)
@ -482,30 +540,30 @@ function mainGameUpdate()
GrassBackground:draw(-400, -240)
local ballIsHeld = fielding:drawFielders(fieldingTeamSprites, ball)
local ballIsHeld = self.fielding:drawFielders(self.state.fieldingTeamSprites, ball)
if offenseState == C.Offense.batting then
if self.state.offenseState == C.Offense.batting then
gfx.setLineWidth(5)
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.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)
for _, runner in pairs(self.baserunning.runners) do
if runner == self.baserunning.batter then
if self.state.batAngleDeg > 50 and self.state.batAngleDeg < 200 then
self.state.battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
else
battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
self.state.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)
self.state.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)
for _, runner in pairs(self.baserunning.outRunners) do
self.state.battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset)
end
if not ballIsHeld then
@ -520,45 +578,20 @@ function mainGameUpdate()
gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(baserunning.runners, fielding.fielders)
drawMinimap(self.baserunning.runners, self.fielding.fielders)
end
drawScoreboard(0, C.Screen.H * 0.77, teams, baserunning.outs, battingTeam, inning)
drawScoreboard(0, C.Screen.H * 0.77, teams, self.baserunning.outs, self.state.battingTeam, self.state.inning)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
announcer:draw(C.Center.x, 10)
self.announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then
playdate.ui.crankIndicator:draw()
end
end
---@param s Settings
local function mainGameInit(s)
settings = s
fielding:resetFielderPositions(teams.home.benchPosition)
playdate.timer.new(2000, function()
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
battingTeamSprites = settings.awayTeamSprites
fieldingTeamSprites = settings.homeTeamSprites
HomeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
AwayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat)
runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
end
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
local function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
MainMenu.start({
mainGameUpdateFunction = mainGameUpdate,
mainGameInitFunction = mainGameInit,
})
end
init()
MainMenu.start(Game)

View File

@ -119,8 +119,8 @@ end
---@param fielders Fielder[]
---@param fielder Fielder
---@param runners Runner[]
---@param launchBall LaunchBall
local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall)
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
local function tryToMakeAPlay(fielders, fielder, runners, ball)
local targetX, targetY = getNextOutTarget(runners)
if targetX ~= nil and targetY ~= nil then
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
@ -130,7 +130,7 @@ local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall)
if nearestFielder == fielder then
ball.heldBy = fielder
else
launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
Fielding.markIneligible(nearestFielder)
end
end
@ -138,16 +138,15 @@ end
---@param fielder Fielder
---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil }
---@param launchBall LaunchBall
function Npc:fielderAction(fielder, outedSomeRunner, ball, launchBall)
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
function Npc:fielderAction(fielder, outedSomeRunner, ball)
if outedSomeRunner then
-- Delay a little before the next play
playdate.timer.new(750, function()
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall)
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end)
else
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall)
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
end
end