Add box score and transitions

Add constants defining the top of the outfield wall (not used yet)
Take scores out of mutable global state (that might be just about all of it sewn up)
Finish switching batttingTeam to a TeamId value
This commit is contained in:
Sage Vaillancourt 2025-02-17 13:21:28 -05:00
parent 6007ac971f
commit 5c45b7bba0
17 changed files with 602 additions and 55 deletions

View File

@ -44,6 +44,7 @@ function Announcer:popIn()
end)
end
---@param text string
function Announcer:say(text)
self.textQueue[#self.textQueue + 1] = text
if #self.textQueue == 1 then

View File

@ -8,6 +8,8 @@ Glove = playdate.graphics.image.new("images/game/Glove.png")
-- luacheck: ignore
PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png")
-- luacheck: ignore
BigBat = playdate.graphics.image.new("images/game/BigBat.png")
-- luacheck: ignore
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
-- luacheck: ignore
GameLogo = playdate.graphics.image.new("images/game/GameLogo.png")

View File

@ -125,6 +125,18 @@ C.WalkedRunnerSpeed = 10
C.ResetFieldersAfterSeconds = 2.5
C.OutfieldWall = {
{ x = 0, y = 137 },
{ x = 233, y = 32 },
{ x = 450, y = 29 },
{ x = 550, y = 59 },
{ x = 739, y = 64 },
{ x = 850, y = 19 },
{ x = 1100, y = 31 },
{ x = 1185, y = 181 },
{ x = 1201, y = 224 },
}
if not playdate then
return C
end

View File

@ -35,8 +35,23 @@ function dbg.loadTheBases(br)
br.runners[4].nextBase = C.Bases[C.Home]
end
---@return BoxScoreData
function dbg.mockBoxScoreData(inningCount)
inningCount = inningCount or 9
local data = {
home = {},
away = {},
}
for i = 1, inningCount do
data.home[i] = math.floor(math.random() * 5)
data.away[i] = math.floor(math.random() * 5)
end
return data
end
---@param points XyPair[]
function dbg.drawLine(points)
playdate.graphics.setColor(playdate.graphics.kColorWhite)
for i = 2, #points do
local prev = points[i - 1]
local next = points[i]

79
src/draw/box-score.lua Normal file
View File

@ -0,0 +1,79 @@
---@class BoxScore
---@field data BoxScoreData
BoxScore = {}
---@param data BoxScoreData
function BoxScore.new(data)
return setmetatable({
data = data,
}, { __index = BoxScore })
end
-- TODO: Convert the box-score into a whole "scene"
-- * Scroll left and right through games that go into extra innings
-- * Scroll up and down through other stats.
-- + Balls and strikes
-- + Batting average
-- + Graph of team scores over time
-- + Farthest hit ball
local MarginY <const> = 70
local ScoreFont <const> = playdate.graphics.font.new("fonts/Asheville-Sans-14-Bold.pft")
local NumWidth <const> = ScoreFont:getTextWidth("0")
local NumHeight <const> = ScoreFont:getHeight()
local AwayWidth <const> = ScoreFont:getTextWidth("AWAY")
local InningMargin = 4
local InningDrawWidth <const> = (InningMargin * 2) + (NumWidth * 2)
local ScoreDrawHeight = NumHeight * 2
-- luacheck: ignore 143
---@type pd_graphics_lib
local gfx = playdate.graphics
local function formatScore(n)
if n <= 9 then
return " " .. n
elseif n <= 19 then
return " " .. n
else
return tostring(n)
end
end
local HomeY <const> = -4 + (NumHeight * 2) + MarginY
local AwayY <const> = -4 + (NumHeight * 3) + MarginY
local function drawInning(x, inningNumber, homeScore, awayScore)
gfx.setColor(gfx.kColorBlack)
gfx.setColor(gfx.kColorWhite)
gfx.setLineWidth(1)
gfx.drawRect(x, 34 + MarginY, InningDrawWidth, ScoreDrawHeight)
inningNumber = " " .. inningNumber
homeScore = formatScore(homeScore)
awayScore = formatScore(awayScore)
x = x - 8 + (InningDrawWidth / 2)
ScoreFont:drawTextAligned(inningNumber, x, -4 + NumHeight + MarginY, gfx.kAlignRight)
ScoreFont:drawTextAligned(awayScore, x, HomeY, gfx.kAlignRight)
ScoreFont:drawTextAligned(homeScore, x, AwayY, gfx.kAlignRight)
end
function BoxScore:update()
local originalDrawMode = gfx.getImageDrawMode()
gfx.clear(gfx.kColorBlack)
gfx.setImageDrawMode(gfx.kDrawModeInverted)
gfx.setColor(gfx.kColorBlack)
local inningStart = 4 + (AwayWidth * 1.5)
local widthAndMarg = InningDrawWidth + 4
ScoreFont:drawTextAligned(" HOME", 10, HomeY, gfx.kAlignRight)
ScoreFont:drawTextAligned("AWAY", 10, AwayY, gfx.kAlignRight)
for i = 1, #self.data.away do
drawInning(inningStart + ((i - 1) * widthAndMarg), i, self.data.home[i], self.data.away[i])
end
local homeScore, awayScore = utils.totalScores(self.data)
drawInning(4 + inningStart + (widthAndMarg * #self.data.away), "F", homeScore, awayScore)
gfx.setImageDrawMode(originalDrawMode)
end

View File

@ -83,11 +83,10 @@ local ScoreboardHeight <const> = 55
local Indicator = "> "
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
---@param teams any
---@param battingTeam any
---@return string, number, string, number
function getIndicators(teams, battingTeam)
if teams.home == battingTeam then
function getIndicators(battingTeam)
if battingTeam == "home" then
return Indicator, 0, "", IndicatorWidth
end
return "", IndicatorWidth, Indicator, 0
@ -101,11 +100,11 @@ local stats = {
battingTeam = nil,
}
function drawScoreboardImpl(x, y, teams)
function drawScoreboardImpl(x, y)
local homeScore = stats.homeScore
local awayScore = stats.awayScore
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, stats.battingTeam)
local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(stats.battingTeam)
local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore)
local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
@ -146,17 +145,17 @@ end
local newStats = stats
function drawScoreboard(x, y, teams, outs, battingTeam, inning)
function drawScoreboard(x, y, homeScore, awayScore, outs, battingTeam, inning)
if
newStats.homeScore ~= teams.home.score
or newStats.awayScore ~= teams.away.score
newStats.homeScore ~= homeScore
or newStats.awayScore ~= awayScore
or newStats.outs ~= outs
or newStats.inning ~= inning
or newStats.battingTeam ~= battingTeam
then
newStats = {
homeScore = teams.home.score,
awayScore = teams.away.score,
homeScore = homeScore,
awayScore = awayScore,
outs = outs,
inning = inning,
battingTeam = battingTeam,
@ -165,5 +164,5 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning)
stats = newStats
end)
end
drawScoreboardImpl(x, y, teams)
drawScoreboardImpl(x, y)
end

106
src/draw/transitions.lua Normal file
View File

@ -0,0 +1,106 @@
Transitions = {
---@type Scene | nil
nextScene = nil,
---@type Scene | nil
previousScene = nil,
}
local gfx = playdate.graphics
-- local function animateOut() end
-- local function animateIn() end
local previousSceneImage
local previousSceneMask
local nextSceneImage
-- local nextSceneOffScreen = false
-- local stillAnimating = false
-- TODO: Okay but like imagine.
-- Push a blank image context,
-- draw the previous scene on one side,
-- draw the next scene on the other,
-- SWING a bat to cover the seam.
local batImageTable = {}
local batOffset = 80
local degStep = 3
function loadBatImageTable()
for deg = 90 - (degStep * 3), 270 + (degStep * 3), degStep do
local img = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(img)
BigBat:drawRotated(C.Center.x, C.Screen.H + batOffset, 90 + deg)
gfx.popContext()
batImageTable[deg] = img
end
end
loadBatImageTable()
local function update()
local lastAngle
local seamAngle = math.rad(270)
while seamAngle > math.rad(90) do
local deltaSeconds = playdate.getElapsedTime()
playdate.resetElapsedTime()
seamAngle = seamAngle - (deltaSeconds * 3)
local seamAngleDeg = math.floor(math.deg(seamAngle))
seamAngleDeg = seamAngleDeg - (seamAngleDeg % degStep)
-- Skip re-drawing if no change
if lastAngle ~= seamAngleDeg then
lastAngle = seamAngleDeg
nextSceneImage:draw(0, 0)
gfx.pushContext(previousSceneMask)
gfx.setImageDrawMode(gfx.kDrawModeFillBlack)
batImageTable[seamAngleDeg]:draw(0, 0)
gfx.popContext()
previousSceneImage:setMaskImage(previousSceneMask)
previousSceneImage:draw(0, 0)
batImageTable[seamAngleDeg]:draw(0, 0)
end
coroutine.yield()
end
playdate.update = function()
Transitions.nextScene:update()
end
end
---@param nextScene fun() The next playdate.update function
function transitionTo(nextScene)
if not Transitions.nextScene then
error("Expected Transitions to already have nextScene defined! E.g. by calling transitionBetween")
end
local previousScene = Transitions.nextScene
transitionBetween(previousScene, nextScene)
end
---@param previousScene Scene Has the current playdate.update function
---@param nextScene Scene Has the next playdate.update function
function transitionBetween(previousScene, nextScene)
playdate.wait(2) -- TODO: There's some sort of timing wack here.
playdate.update = update
previousSceneImage = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(previousSceneImage)
previousScene:update()
gfx.popContext()
nextSceneImage = gfx.image.new(C.Screen.W, C.Screen.H)
gfx.pushContext(nextSceneImage)
nextScene:update()
gfx.popContext()
previousSceneMask = gfx.image.new(C.Screen.W, C.Screen.H, gfx.kColorWhite)
previousSceneImage:setMaskImage(previousSceneMask)
Transitions.nextScene = nextScene
Transitions.previousScene = previousScene
end

View File

@ -182,7 +182,7 @@ function Fielding:userThrowTo(targetBase, ball, throwFlyMs)
end)
end
function Fielding:celebrate()
function Fielding.celebrate()
FielderDanceAnimator:reset(C.DanceBounceMs)
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,295 @@
tracking=1
space 3
! 2
" 5
# 9
$ 8
% 12
& 11
' 3
( 5
) 5
* 8
+ 8
, 3
- 6
. 2
/ 6
0 9
1 4
2 9
3 9
4 9
5 9
6 9
7 9
8 10
9 9
: 2
; 2
< 7
= 7
> 7
? 9
@ 11
A 10
B 9
C 9
D 9
E 8
F 8
G 9
H 9
I 2
J 8
K 10
L 9
M 12
N 9
O 9
P 9
Q 9
R 9
S 9
T 10
U 9
V 10
W 14
X 8
Y 8
Z 8
[ 3
\ 6
] 3
^ 6
_ 8
` 3
a 8
b 8
c 8
d 8
e 8
f 6
g 8
h 8
i 2
j 4
k 8
l 2
m 12
n 8
o 8
p 8
q 8
r 6
s 8
t 6
u 8
v 8
w 12
x 9
y 8
z 8
{ 6
| 2
} 6
~ 10
… 8
¥ 8
‼ 5
™ 8
© 11
® 11
。 16
、 16
ぁ 16
あ 16
ぃ 16
い 16
ぅ 16
う 16
ぇ 16
え 16
ぉ 16
お 16
か 16
が 16
き 16
ぎ 16
く 16
ぐ 16
け 16
げ 16
こ 16
ご 16
さ 16
ざ 16
し 16
じ 16
す 16
ず 16
せ 16
ぜ 16
そ 16
ぞ 16
た 16
だ 16
ち 16
ぢ 16
っ 16
つ 16
づ 16
て 16
で 16
と 16
ど 16
な 16
に 16
ぬ 16
ね 16
の 16
は 16
ば 16
ぱ 16
ひ 16
び 16
ぴ 16
ふ 16
ぶ 16
ぷ 16
へ 16
べ 16
ぺ 16
ほ 16
ぼ 16
ぽ 16
ま 16
み 16
む 16
め 16
も 16
ゃ 16
や 16
ゅ 16
ゆ 16
ょ 16
よ 16
ら 16
り 16
る 16
れ 16
ろ 16
ゎ 16
わ 16
ゐ 16
ゑ 16
を 16
ん 16
ゔ 16
ゕ 16
ゖ 16
゛ 1
゜ 0
ゝ 16
ゞ 16
ゟ 16
16
ァ 16
ア 16
ィ 16
イ 16
ゥ 16
ウ 16
ェ 16
エ 16
ォ 16
オ 16
カ 16
ガ 16
キ 16
ギ 16
ク 16
グ 16
ケ 16
ゲ 16
コ 16
ゴ 16
サ 16
ザ 16
シ 16
ジ 16
ス 16
ズ 16
セ 16
ゼ 16
ソ 16
ゾ 16
タ 16
ダ 16
チ 16
ヂ 16
ッ 16
ツ 16
ヅ 16
テ 16
デ 16
ト 16
ド 16
ナ 16
ニ 16
ヌ 16
ネ 16
16
ハ 16
バ 16
パ 16
ヒ 16
ビ 16
ピ 16
フ 16
ブ 16
プ 16
ヘ 16
ベ 16
ペ 16
ホ 16
ボ 16
ポ 16
マ 16
ミ 16
ム 16
メ 16
モ 16
ャ 16
ヤ 16
ュ 16
ユ 16
ョ 16
ヨ 16
ラ 16
リ 16
ル 16
レ 16
ロ 16
ヮ 16
ワ 16
ヰ 16
ヱ 16
ヲ 16
ン 16
ヴ 16
ヵ 16
ヶ 16
ヷ 16
ヸ 16
ヹ 16
ヺ 16
・ 16
ー 16
ヽ 16
ヾ 16
ヿ 16
「 16
」 16
円 16
<EFBFBD> 13

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
src/images/game/BigBat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -24,9 +24,7 @@ local function startGame()
awayTeamSprites = AwayTeamSprites,
})
playdate.resetElapsedTime()
playdate.update = function()
next:update()
end
transitionBetween(MainMenu, next)
end
local function pausingEaser(baseEaser)
@ -64,9 +62,10 @@ local function arrayElementFromCrank(array, crankPosition)
return array[i]
end
local currentLogo = nil
local currentLogo
function MainMenu.update()
--luacheck: ignore
function MainMenu:update()
playdate.timer.updateTimers()
crankStartPos = crankStartPos or playdate.getCrankPosition()

View File

@ -8,6 +8,8 @@ import 'CoreLibs/timer.lua'
import 'CoreLibs/ui.lua'
-- stylua: ignore end
--- @alias Scene { update: fun(self: self) }
--- @alias EasingFunc fun(number, number, number, number): number
--- @alias LaunchBall fun(
@ -24,9 +26,11 @@ import 'CoreLibs/ui.lua'
import 'utils.lua'
import 'constants.lua'
import 'assets.lua'
import 'draw/player.lua'
import 'draw/overlay.lua'
import 'draw/box-score.lua'
import 'draw/fielder.lua'
import 'draw/overlay.lua'
import 'draw/player.lua'
import 'draw/transitions.lua'
import 'main-menu.lua'
@ -44,19 +48,18 @@ import 'npc.lua'
local gfx <const>, C <const> = playdate.graphics, C
---@alias Team { score: number, benchPosition: XyPair }
---@alias Team { benchPosition: XyPair }
---@type table<TeamId, Team>
local teams <const> = {
home = {
score = 0, -- TODO: Extract this last bit of global mutable state.
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
},
away = {
score = 0,
benchPosition = utils.xy(-10, C.Center.y),
},
}
---@alias BoxScoreData table<TeamId, number[]>
---@alias TeamId 'home' | 'away'
--- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
@ -73,6 +76,7 @@ local teams <const> = {
---@field catcherThrownBall boolean
---@field offenseState OffenseState
---@field inning number
---@field boxScore BoxScoreData
---@field batBase XyPair
---@field batTip XyPair
---@field batAngleDeg number
@ -104,8 +108,6 @@ Game = {}
---@param state MutableState | nil
---@return Game
function Game.new(settings, announcer, fielding, baserunning, npc, state)
teams.away.score = 0
teams.home.score = 0
announcer = announcer or Announcer.new()
fielding = fielding or Fielding.new()
settings.userTeam = nil -- "away"
@ -137,6 +139,10 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state)
battingTeamSprites = settings.awayTeamSprites,
fieldingTeamSprites = settings.homeTeamSprites,
runnerBlipper = runnerBlipper,
boxScore = {
home = { 0 },
away = { 0 },
},
},
}, { __index = Game })
@ -210,10 +216,6 @@ local function getOppositeTeamId(teamId)
end
end
function Game:getBattingTeam()
return teams[self.state.battingTeam] or error("Unknown battingTeam: " .. (self.state.battingTeam or "nil"))
end
function Game:getFieldingTeam()
return teams[getOppositeTeamId(self.state.battingTeam)]
end
@ -265,40 +267,49 @@ end
function Game:nextHalfInning()
pitchTracker:reset()
local gameOver = self.state.inning == self.settings.finalInning and teams.away.score ~= teams.home.score
if not gameOver then
self.fielding:celebrate()
self.state.secondsSinceLastRunnerMove = -7
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...")
end
local homeScore, awayScore = utils.totalScores(self.state.boxScore)
local gameOver = self.state.battingTeam == "home"
and self.state.inning == self.settings.finalInning
and awayScore ~= homeScore
if gameOver then
self.announcer:say("AND THAT'S THE BALL GAME!")
else
self.fielding:resetFielderPositions()
if self.state.battingTeam == teams.home then
self.state.inning = self.state.inning + 1
end
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
playdate.timer.new(2000, function()
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
self.state.battingTeamSprites = self.settings.awayTeamSprites
self.state.fieldingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.awayTeamBlipper
end
playdate.timer.new(3000, function()
transitionTo(BoxScore.new(self.state.boxScore))
end)
return
end
Fielding.celebrate()
self.state.secondsSinceLastRunnerMove = -7
self.fielding:benchTo(self:getFieldingTeam().benchPosition)
self.announcer:say("SWITCHING SIDES...")
self.fielding:resetFielderPositions()
if self.state.battingTeam == "home" then
self.state.inning = self.state.inning + 1
self.state.boxScore.home[self.state.inning] = 0
self.state.boxScore.away[self.state.inning] = 0
end
self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
playdate.timer.new(2000, function()
if self.state.battingTeam == "home" then
self.state.battingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.homeTeamBlipper
self.state.fieldingTeamSprites = self.settings.awayTeamSprites
else
self.state.battingTeamSprites = self.settings.awayTeamSprites
self.state.fieldingTeamSprites = self.settings.homeTeamSprites
self.state.runnerBlipper = self.awayTeamBlipper
end
end)
end
---@param scoredRunCount number
function Game:score(scoredRunCount)
local batting = self:getBattingTeam()
batting.score = batting.score + scoredRunCount
local battingTeamBoxScore = self.state.boxScore[self.state.battingTeam]
battingTeamBoxScore[self.state.inning] = battingTeamBoxScore[self.state.inning] + scoredRunCount
self.announcer:say("SCORE!")
end
@ -513,7 +524,8 @@ function Game:updateGameState()
if self.state.secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
-- End of play. Throw the ball back to the pitcher
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.
-- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
self.fielding:markAllIneligible()
self.fielding:resetFielderPositions()
self.state.offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter()
@ -629,11 +641,21 @@ function Game:update()
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
drawMinimap(self.baserunning.runners, self.fielding.fielders)
end
drawScoreboard(0, C.Screen.H * 0.77, teams, self.baserunning.outs, self:getBattingTeam(), self.state.inning)
local homeScore, awayScore = utils.totalScores(self.state.boxScore)
drawScoreboard(
0,
C.Screen.H * 0.77,
homeScore,
awayScore,
self.baserunning.outs,
self.state.battingTeam,
self.state.inning
)
drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
self.announcer:draw(C.Center.x, 10)
if playdate.isCrankDocked() then
-- luacheck: ignore
playdate.ui.crankIndicator:draw()
end
end

View File

@ -22,6 +22,7 @@ end
---@param catcherThrownBall boolean
---@param deltaSec number
---@return number
-- luacheck: no unused
function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec)
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)

View File

@ -232,6 +232,22 @@ function utils.getNearestOf(array, x, y, extraCondition)
return nearest, nearestDistance
end
---@param box BoxScore
---@return number homeScore, number awayScore
function utils.totalScores(box)
local homeScore = 0
for _, score in pairs(box.home) do
homeScore = homeScore + score
end
local awayScore = 0
for _, score in pairs(box.away) do
awayScore = awayScore + score
end
return homeScore, awayScore
end
PitchOutcomes = {
StrikeOut = {},
Walk = {},