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 floaty boolean | nil
---@param customBallScaler pd_animator | nil ---@param customBallScaler pd_animator | nil
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
throwMeter:reset()
self.heldBy = nil self.heldBy = nil
if not flyTimeMs then if not flyTimeMs then

View File

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

View File

@ -20,6 +20,8 @@ function getDrawOffset(ballX, ballY)
return offsetX * 1.3, offsetY * 1.5 return offsetX * 1.3, offsetY * 1.5
end end
---@class Blipper
---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number)
-- selene: allow(unscoped_variables) -- selene: allow(unscoped_variables)
blipper = {} blipper = {}
@ -39,9 +41,3 @@ function blipper.new(msInterval, smiling, lowHat)
end, end,
} }
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) -- selene: allow(unscoped_variables)
---@class MainMenu ---@class MainMenu
MainMenu = { MainMenu = {
mainGameUpdateFunction = nil, ---@type { new: fun(settings: Settings): { update: fun(self) } }
---@type fun(settings: Settings) next = nil,
mainGameInitFunction = nil,
} }
-- selene: allow(shadowing) -- selene: allow(shadowing)
local gfx = playdate.graphics local gfx = playdate.graphics
@ -12,24 +11,25 @@ local gfx = playdate.graphics
local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft") local StartFont <const> = gfx.font.new("fonts/Roobert-20-Medium.pft")
--- Take control of playdate.update --- Take control of playdate.update
---@param config MainMenu Function that controls the main gameplay loop.
--- Will replace playdate.update when the menu is done. --- Will replace playdate.update when the menu is done.
function MainMenu.start(config) ---@param next { new: fun(settings: Settings): { update: fun(self) } }
MainMenu.mainGameUpdateFunction = config.mainGameUpdateFunction function MainMenu.start(next)
MainMenu.mainGameInitFunction = config.mainGameInitFunction MainMenu.next = next
playdate.update = MainMenu.update playdate.update = MainMenu.update
end end
local inningCountSelection = 3 local inningCountSelection = 3
local function startGame() local function startGame()
MainMenu.mainGameInitFunction({ local next = MainMenu.next.new({
finalInning = inningCountSelection, finalInning = inningCountSelection,
homeTeamSprites = HomeTeamSprites, homeTeamSprites = HomeTeamSprites,
awayTeamSprites = AwayTeamSprites, awayTeamSprites = AwayTeamSprites,
}) })
playdate.resetElapsedTime() playdate.resetElapsedTime()
playdate.update = MainMenu.mainGameUpdateFunction playdate.update = function()
next:update()
end
end end
local function pausingEaser(baseEaser) local function pausingEaser(baseEaser)

View File

@ -11,6 +11,7 @@ import 'CoreLibs/ui.lua'
--- @alias EasingFunc fun(number, number, number, number): number --- @alias EasingFunc fun(number, number, number, number): number
--- @alias LaunchBall fun( --- @alias LaunchBall fun(
--- self: self,
--- destX: number, --- destX: number,
--- destY: number, --- destY: number,
--- easingFunc: EasingFunc, --- easingFunc: EasingFunc,
@ -44,34 +45,6 @@ import 'npc.lua'
-- selene: allow(shadowing) -- selene: allow(shadowing)
local gfx <const>, C <const> = playdate.graphics, C 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 } ---@alias Team { score: number, benchPosition: XyPair }
---@type table<string, Team> ---@type table<string, Team>
local teams <const> = { local teams <const> = {
@ -85,49 +58,133 @@ local teams <const> = {
}, },
} }
local inning = 1 --- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
---@class Settings
local battingTeam = teams.away ---@field finalInning number
local offenseState = C.Offense.batting ---@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 -- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
local secondsSinceLastRunnerMove = 0 ---@field secondsSinceLastRunnerMove number
local secondsSincePitchAllowed = 0 ---@field secondsSincePitchAllowed number
-- These are only sort-of global state. They are purely graphical, -- 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. -- but they need to be kept in sync with the rest of the globals.
local runnerBlipper ---@field runnerBlipper Blipper
local battingTeamSprites ---@field battingTeamSprites SpriteCollection
local fieldingTeamSprites ---@field fieldingTeamSprites SpriteCollection
------------------------- ---@class Game
-- END OF GLOBAL STATE -- ---@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 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[] ---@type Pitch[]
local Pitches <const> = { local Pitches <const> = {
-- Fastball -- Fastball
{ function()
return {
x = gfx.animator.new(0, C.PitchStartX, C.PitchStartX, playdate.easingFunctions.linear), 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), y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}, }
end,
-- Curve ball -- Curve ball
{ function()
return {
x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX + 20, C.PitchStartX, utils.easingHill), 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), y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}, }
end,
-- Slider -- Slider
{ function()
return {
x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX - 20, C.PitchStartX, utils.easingHill), 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), y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}, }
end,
-- Wobbbleball -- Wobbbleball
{ function(ball)
return {
x = { x = {
currentValue = function() currentValue = function()
return C.PitchStartX + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStartY) / 10)) return C.PitchStartX + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStartY) / 10))
@ -135,17 +192,18 @@ local Pitches <const> = {
reset = function() end, reset = function() end,
}, },
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
}, }
end,
} }
---@return boolean userIsOnSide, boolean userIsOnOtherSide ---@return boolean userIsOnSide, boolean userIsOnOtherSide
local function userIsOn(side) function Game:userIsOn(side)
if UserTeam == nil then if self.settings.userTeam == nil then
-- Both teams are NPC-driven -- Both teams are NPC-driven
return false, false return false, false
end end
local ret local ret
if UserTeam == battingTeam then if self.settings.userTeam == self.state.battingTeam then
ret = side == C.Sides.offense ret = side == C.Sides.offense
else else
ret = side == C.Sides.defense ret = side == C.Sides.defense
@ -153,86 +211,77 @@ local function userIsOn(side)
return ret, not ret return ret, not ret
end 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 pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil ---@param pitchTypeIndex number | nil
local function pitch(pitchFlyTimeMs, pitchTypeIndex) function Game:pitch(pitchFlyTimeMs, pitchTypeIndex)
Fielding.markIneligible(fielding.fielders.pitcher) Fielding.markIneligible(self.fielding.fielders.pitcher)
ball.heldBy = nil self.state.ball.heldBy = nil
catcherThrownBall = false self.state.catcherThrownBall = false
offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
local current = Pitches[pitchTypeIndex] local current = Pitches[pitchTypeIndex](self.state.ball)
ball.xAnimator = current.x self.state.ball.xAnimator = current.x
ball.yAnimator = current.y or Pitches[1].y 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 -- if current.z then
-- ball.floatAnimator = current.z -- ball.floatAnimator = current.z
-- ball.floatAnimator:reset() -- ball.floatAnimator:reset()
-- end -- end
if pitchFlyTimeMs then if pitchFlyTimeMs then
ball.xAnimator:reset(pitchFlyTimeMs) self.state.ball.xAnimator:reset(pitchFlyTimeMs)
ball.yAnimator:reset(pitchFlyTimeMs) self.state.ball.yAnimator:reset(pitchFlyTimeMs)
else else
ball.xAnimator:reset() self.state.ball.xAnimator:reset()
ball.yAnimator:reset() self.state.ball.yAnimator:reset()
end end
secondsSincePitchAllowed = 0 self.state.secondsSincePitchAllowed = 0
end end
local function nextHalfInning() function Game:nextHalfInning()
pitchTracker:reset() pitchTracker:reset()
local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home local currentlyFieldingTeam = self.state.battingTeam == teams.home and teams.away or teams.home
local gameOver = inning == settings.finalInning and teams.away.score ~= teams.home.score local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score
if not gameOver then if not gameOver then
fielding:celebrate() self.fielding:celebrate()
secondsSinceLastRunnerMove = -7 self.state.secondsSinceLastRunnerMove = -7
fielding:benchTo(currentlyFieldingTeam.benchPosition) self.fielding:benchTo(currentlyFieldingTeam.benchPosition)
announcer:say("SWITCHING SIDES...") self.announcer:say("SWITCHING SIDES...")
end end
if gameOver then if gameOver then
announcer:say("AND THAT'S THE BALL GAME!") self.announcer:say("AND THAT'S THE BALL GAME!")
else else
fielding:resetFielderPositions() self.fielding:resetFielderPositions()
if battingTeam == teams.home then if self.state.battingTeam == teams.home then
inning = inning + 1 self.state.inning = self.state.inning + 1
end end
battingTeam = currentlyFieldingTeam self.state.battingTeam = currentlyFieldingTeam
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
if battingTeam == teams.home then if self.state.battingTeam == teams.home then
battingTeamSprites = settings.homeTeamSprites self.state.battingTeamSprites = self.settings.homeTeamSprites
runnerBlipper = HomeTeamBlipper self.state.runnerBlipper = self.homeTeamBlipper
fieldingTeamSprites = settings.awayTeamSprites self.state.fieldingTeamSprites = self.settings.awayTeamSprites
else else
battingTeamSprites = settings.awayTeamSprites self.state.battingTeamSprites = self.settings.awayTeamSprites
fieldingTeamSprites = settings.homeTeamSprites self.state.fieldingTeamSprites = self.settings.homeTeamSprites
runnerBlipper = AwayTeamBlipper self.state.runnerBlipper = self.awayTeamBlipper
end end
end) end)
end end
end end
local baserunning = Baserunning.new(announcer, nextHalfInning)
local npc = Npc.new(baserunning.runners, fielding.fielders)
---@param scoredRunCount number ---@param scoredRunCount number
local function score(scoredRunCount) function Game:score(scoredRunCount)
battingTeam.score = battingTeam.score + scoredRunCount self.state.battingTeam.score = self.state.battingTeam.score + scoredRunCount
announcer:say("SCORE!") self.announcer:say("SCORE!")
end end
---@param throwFlyMs number ---@param throwFlyMs number
---@return boolean didThrow ---@return boolean didThrow
local function buttonControlledThrow(throwFlyMs, forbidThrowHome) function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
local targetBase local targetBase
if playdate.buttonIsPressed(playdate.kButtonLeft) then if playdate.buttonIsPressed(playdate.kButtonLeft) then
targetBase = C.Bases[C.Third] targetBase = C.Bases[C.Third]
@ -246,62 +295,67 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
return false return false
end end
-- Power for this throw has already been determined self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
throwMeter:reset() self.state.secondsSinceLastRunnerMove = 0
self.state.offenseState = C.Offense.running
fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
secondsSinceLastRunnerMove = 0
offenseState = C.Offense.running
return true return true
end end
local function nextBatter() function Game:nextBatter()
secondsSincePitchAllowed = -3 self.state.secondsSincePitchAllowed = -3
baserunning.batter = nil self.baserunning.batter = nil
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
pitchTracker:reset() pitchTracker:reset()
if not baserunning.batter then if not self.baserunning.batter then
baserunning:pushNewBatter() self.baserunning:pushNewBatter()
end end
end) end)
end end
local function walk() function Game:walk()
announcer:say("Walk!") self.announcer:say("Walk!")
-- TODO? Use baserunning:convertBatterToRunner() -- TODO? Use self.baserunning:convertBatterToRunner()
baserunning.batter.nextBase = C.Bases[C.First] self.baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home] self.baserunning.batter.prevBase = C.Bases[C.Home]
offenseState = C.Offense.walking self.state.offenseState = C.Offense.walking
baserunning.batter = nil self.baserunning.batter = nil
baserunning:updateForcedRunners() self.baserunning:updateForcedRunners()
nextBatter() self:nextBatter()
end end
local function strikeOut() function Game:strikeOut()
local outBatter = baserunning.batter local outBatter = self.baserunning.batter
baserunning.batter = nil self.baserunning.batter = nil
baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!") self.baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
nextBatter() self:nextBatter()
end end
---@param batDeg number ---@param batDeg number
local function updateBatting(batDeg, batSpeed) function Game:updateBatting(batDeg, batSpeed)
local batAngle = math.rad(batDeg) local batAngle = math.rad(batDeg)
-- TODO: animate bat-flip or something -- TODO: animate bat-flip or something
batBase.x = baserunning.batter and (baserunning.batter.x + C.BatterHandPos.x) or 0 self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0
batBase.y = baserunning.batter and (baserunning.batter.y + C.BatterHandPos.y) or 0 self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0
batTip.x = batBase.x + (C.BatLength * math.sin(batAngle)) self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
batTip.y = batBase.y + (C.BatLength * math.cos(batAngle)) self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
if if
batSpeed > 0 batSpeed > 0
and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H) and utils.pointDirectlyUnderLine(
and ball.y < 232 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 then
-- Hit! -- Hit!
BatCrackReverb:play() BatCrackReverb:play()
offenseState = C.Offense.running self.state.offenseState = C.Offense.running
local ballAngle = batAngle + math.rad(90) local ballAngle = batAngle + math.rad(90)
local mult = math.abs(batSpeed / 15) local mult = math.abs(batSpeed / 15)
@ -311,52 +365,54 @@ local function updateBatting(batDeg, batSpeed)
ballVelX = ballVelX * -1 ballVelX = ballVelX * -1
ballVelY = ballVelY * -1 ballVelY = ballVelY * -1
end end
local ballDestX = ball.x + (ballVelX * C.BattingPower) local ballDestX = self.state.ball.x + (ballVelX * C.BattingPower)
local ballDestY = ball.y + (ballVelY * C.BattingPower) local ballDestY = self.state.ball.y + (ballVelY * C.BattingPower)
pitchTracker:reset() pitchTracker:reset()
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) 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. -- TODO? A dramatic eye-level view on a home-run could be sick.
if utils.isFoulBall(ballDestX, ballDestY) then if utils.isFoulBall(ballDestX, ballDestY) then
announcer:say("Foul ball!") self.announcer:say("Foul ball!")
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2) pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
-- TODO: Have a fielder chase for the fly-out -- TODO: Have a fielder chase for the fly-out
return return
end end
baserunning:convertBatterToRunner() self.baserunning:convertBatterToRunner()
fielding:haveSomeoneChase(ballDestX, ballDestY) self.fielding:haveSomeoneChase(ballDestX, ballDestY)
end end
end end
---@param appliedSpeed number | fun(runner: Runner): number ---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean someRunnerMoved ---@return boolean someRunnerMoved
local function updateNonBatterRunners(appliedSpeed, forcedOnly) function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) local runnerMoved, runnersScored =
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
if runnersScored ~= 0 then if runnersScored ~= 0 then
score(runnersScored) self:score(runnersScored)
end end
return runnerMoved return runnerMoved
end end
local function userPitch(throwFly) ---@param throwFly number
local aButton = playdate.buttonIsPressed(playdate.kButtonA) function Game:userPitch(throwFly)
local bButton = playdate.buttonIsPressed(playdate.kButtonB) local aPressed = playdate.buttonIsPressed(playdate.kButtonA)
if not aButton and not bButton then local bPressed = playdate.buttonIsPressed(playdate.kButtonB)
pitch(throwFly, 1) if not aPressed and not bPressed then
elseif aButton and not bButton then self:pitch(throwFly, 1)
pitch(throwFly, 2) elseif aPressed and not bPressed then
elseif not aButton and bButton then self:pitch(throwFly, 2)
pitch(throwFly, 3) elseif not aPressed and bPressed then
elseif aButton and bButton then self:pitch(throwFly, 3)
pitch(throwFly, 4) elseif aPressed and bPressed then
self:pitch(throwFly, 4)
end end
end end
local function updateGameState() function Game:updateGameState()
deltaSeconds = playdate.getElapsedTime() or 0 self.state.deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime() playdate.resetElapsedTime()
local crankChange = playdate.getCrankChange() --[[@as number]] local crankChange = playdate.getCrankChange() --[[@as number]]
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower) local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
@ -364,115 +420,117 @@ local function updateGameState()
crankLimited = crankLimited * -1 crankLimited = crankLimited * -1
end 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 if userOnDefense then
throwMeter:applyCharge(deltaSeconds, crankLimited) throwMeter:applyCharge(self.state.deltaSeconds, crankLimited)
end end
if offenseState == C.Offense.batting then if self.state.offenseState == C.Offense.batting then
if ball.y < C.StrikeZoneStartY then if self.state.ball.y < C.StrikeZoneStartY then
pitchTracker.recordedPitchX = nil pitchTracker.recordedPitchX = nil
elseif not pitchTracker.recordedPitchX then elseif not pitchTracker.recordedPitchX then
pitchTracker.recordedPitchX = ball.x pitchTracker.recordedPitchX = self.state.ball.x
end 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 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 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() local outcome = pitchTracker:updatePitchCounts()
if outcome == PitchOutcomes.StrikeOut then if outcome == PitchOutcomes.StrikeOut then
strikeOut() self:strikeOut()
elseif outcome == PitchOutcomes.Walk then elseif outcome == PitchOutcomes.Walk then
walk() self:walk()
end end
-- Catcher has the ball. Throw it back to the pitcher -- Catcher has the ball. Throw it back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) self.state.ball:launch(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
catcherThrownBall = true self.state.catcherThrownBall = true
end end
local batSpeed local batSpeed
if userOnOffense then if userOnOffense then
batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
batSpeed = crankLimited batSpeed = crankLimited
else else
batAngleDeg = npc:updateBatAngle(ball, catcherThrownBall, deltaSeconds) self.state.batAngleDeg =
batSpeed = npc:batSpeed() * deltaSeconds self.npc:updateBatAngle(self.state.ball, self.state.catcherThrownBall, self.state.deltaSeconds)
batSpeed = self.npc:batSpeed() * self.state.deltaSeconds
end end
updateBatting(batAngleDeg, batSpeed) self:updateBatting(self.state.batAngleDeg, batSpeed)
-- Walk batter to the plate -- Walk batter to the plate
-- TODO: Ensure batter can't be nil, here -- 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 if userOnDefense then
local throwFly = throwMeter:readThrow() local throwFly = throwMeter:readThrow()
if throwFly and not buttonControlledThrow(throwFly, true) then if throwFly and not self:buttonControlledThrow(throwFly, true) then
userPitch(throwFly) self:userPitch(throwFly)
end end
else else
pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches)) self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), math.random(#Pitches))
end end
end end
elseif offenseState == C.Offense.running then elseif self.state.offenseState == C.Offense.running then
local appliedSpeed = userOnOffense and crankLimited local appliedSpeed = userOnOffense and crankLimited
or function(runner) or function(runner)
return npc:runningSpeed(runner, ball) return self.npc:runningSpeed(runner, self.state.ball)
end end
if updateNonBatterRunners(appliedSpeed) then if self:updateNonBatterRunners(appliedSpeed) then
secondsSinceLastRunnerMove = 0 self.state.secondsSinceLastRunnerMove = 0
else else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds self.state.secondsSinceLastRunnerMove = self.state.secondsSinceLastRunnerMove + self.state.deltaSeconds
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher -- End of play. Throw the ball back to the pitcher
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) self.state.ball:launch(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. self.fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
fielding:resetFielderPositions() self.fielding:resetFielderPositions()
offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter() -- TODO: Remove, or replace with nextBatter()
if not baserunning.batter then if not self.baserunning.batter then
baserunning:pushNewBatter() self.baserunning:pushNewBatter()
end end
end end
end end
elseif offenseState == C.Offense.walking then elseif self.state.offenseState == C.Offense.walking then
if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
offenseState = C.Offense.batting self.state.offenseState = C.Offense.batting
end end
end end
local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds) local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
if userOnDefense then if userOnDefense then
local throwFly = throwMeter:readThrow() local throwFly = throwMeter:readThrow()
if throwFly then if throwFly then
buttonControlledThrow(throwFly) self:buttonControlledThrow(throwFly)
end end
end end
if fielderHoldingBall then if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) local outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
if not userOnDefense and offenseState == C.Offense.running then if not userOnDefense and self.state.offenseState == C.Offense.running then
npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall) self.npc:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
end end
end end
baserunning:walkAwayOutRunners(deltaSeconds) self.baserunning:walkAwayOutRunners(self.state.deltaSeconds)
actionQueue:runWaiting(deltaSeconds) actionQueue:runWaiting(self.state.deltaSeconds)
end end
-- TODO: Swappable update() for main menu, etc. -- TODO: Swappable update() for main menu, etc.
function mainGameUpdate() function Game:update()
playdate.timer.updateTimers() playdate.timer.updateTimers()
gfx.animation.blinker.updateAll() gfx.animation.blinker.updateAll()
updateGameState() self:updateGameState()
local ball = self.state.ball
gfx.clear() gfx.clear()
gfx.setColor(gfx.kColorBlack) gfx.setColor(gfx.kColorBlack)
@ -482,30 +540,30 @@ function mainGameUpdate()
GrassBackground:draw(-400, -240) 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.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 end
local playerHeightOffset = 10 local playerHeightOffset = 10
-- TODO? Scale sprites down as y increases -- TODO? Scale sprites down as y increases
for _, runner in pairs(baserunning.runners) do for _, runner in pairs(self.baserunning.runners) do
if runner == baserunning.batter then if runner == self.baserunning.batter then
if batAngleDeg > 50 and batAngleDeg < 200 then if self.state.batAngleDeg > 50 and self.state.batAngleDeg < 200 then
battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset) self.state.battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
else else
battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset) self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
end end
else else
-- TODO? Change blip speed depending on runner speed? -- 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
end end
for _, runner in pairs(baserunning.outRunners) do for _, runner in pairs(self.baserunning.outRunners) do
battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset) self.state.battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset)
end end
if not ballIsHeld then if not ballIsHeld then
@ -520,45 +578,20 @@ function mainGameUpdate()
gfx.setDrawOffset(0, 0) gfx.setDrawOffset(0, 0)
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(baserunning.runners, fielding.fielders) drawMinimap(self.baserunning.runners, self.fielding.fielders)
end 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) 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 if playdate.isCrankDocked() then
playdate.ui.crankIndicator:draw() playdate.ui.crankIndicator:draw()
end end
end end
---@param s Settings playdate.display.setRefreshRate(50)
local function mainGameInit(s) gfx.setBackgroundColor(gfx.kColorWhite)
settings = s 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)
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
local function init() MainMenu.start(Game)
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()

View File

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