diff --git a/src/assets.lua b/src/assets.lua index 1a9b8a6..d1a5dba 100644 --- a/src/assets.lua +++ b/src/assets.lua @@ -26,6 +26,8 @@ Minimap = playdate.graphics.image.new("images/game/Minimap.png") -- luacheck: ignore GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png") -- luacheck: ignore +GrassBackgroundSmall = playdate.graphics.image.new("images/game/GrassBackgroundSmall.png") +-- luacheck: ignore LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png") -- luacheck: ignore LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png") diff --git a/src/dbg.lua b/src/dbg.lua index f92ffa6..7005789 100644 --- a/src/dbg.lua +++ b/src/dbg.lua @@ -35,23 +35,69 @@ function dbg.loadTheBases(br) br.runners[4].nextBase = C.Bases[C.Home] end ----@return BoxScoreData -function dbg.mockBoxScoreData(inningCount) +local hitSamples = { + away = { + { + utils.xy(7.88733, -16.3434), + utils.xy(378.3376, 30.49521), + utils.xy(367.1036, 21.55336), + }, + { + utils.xy(379.8051, -40.82794), + utils.xy(-444.5791, -30.30901), + utils.xy(-30.43079, -30.50307), + }, + { + utils.xy(227.8881, -14.56854), + utils.xy(293.5208, 39.38919), + utils.xy(154.4738, -26.55899), + }, + }, + home = { + { + utils.xy(146.2505, -89.12155), + utils.xy(429.5428, 59.62944), + utils.xy(272.4666, -78.578), + }, + { + utils.xy(485.0516, 112.8341), + utils.xy(290.9232, -4.946442), + utils.xy(263.4262, -6.482407), + }, + { + utils.xy(260.6927, -63.63049), + utils.xy(392.1548, -44.22421), + utils.xy(482.5545, 105.3476), + utils.xy(125.5928, 18.53091), + }, + }, +} + +---@return Statistics +function dbg.mockStatistics(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) + local stats = Statistics.new() + for i = 1, inningCount - 1 do + stats.innings[i].home.score = math.floor(math.random() * 5) + stats.innings[i].away.score = math.floor(math.random() * 5) + if hitSamples.home[i] ~= nil then + stats.innings[i].home.hits = hitSamples.home[i] + end + if hitSamples.away[i] ~= nil then + stats.innings[i].away.hits = hitSamples.away[i] + end + stats:pushInning() end - return data + + local homeScore, awayScore = utils.totalScores(stats) + if homeScore == awayScore then + stats.innings[#stats.innings].home.score = 1 + stats.innings[#stats.innings].home.score + end + return stats 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] diff --git a/src/draw/box-score.lua b/src/draw/box-score.lua index 6fcd8e1..f6fcced 100644 --- a/src/draw/box-score.lua +++ b/src/draw/box-score.lua @@ -1,11 +1,51 @@ +---@alias TeamInningData { score: number, pitching: { balls: number, strikes: number }, hits: XyPair[] } + +--- E.g. statistics[1].home.pitching.balls +---@class Statistics +---@field innings: (table)[] + +Statistics = {} + +local function newTeamInning() + return { + score = 0, + pitching = { + balls = 0, + strikes = 0, + }, + hits = {}, + } +end + +---@return table +local function newInning() + return { + home = newTeamInning(), + away = newTeamInning(), + } +end + +---@return Statistics +function Statistics.new() + return setmetatable({ + innings = { newInning() }, + }, { __index = Statistics }) +end + +function Statistics:pushInning() + self.innings[#self.innings + 1] = newInning() +end + ---@class BoxScore ----@field data BoxScoreData +---@field stats Statistics +---@field private targetY number BoxScore = {} ----@param data BoxScoreData -function BoxScore.new(data) +---@param stats Statistics +function BoxScore.new(stats) return setmetatable({ - data = data, + stats = stats, + targetY = 0, }, { __index = BoxScore }) end @@ -19,6 +59,7 @@ end local MarginY = 70 +local SmallFont = playdate.graphics.font.new("fonts/font-full-circle.pft") local ScoreFont = playdate.graphics.font.new("fonts/Asheville-Sans-14-Bold.pft") local NumWidth = ScoreFont:getTextWidth("0") local NumHeight = ScoreFont:getHeight() @@ -61,19 +102,147 @@ local function drawInning(x, inningNumber, homeScore, awayScore) 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) +function BoxScore:drawBoxScore() 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]) + for i = 1, #self.stats.innings do + local inningStats = self.stats.innings[i] + drawInning(inningStart + ((i - 1) * widthAndMarg), i, inningStats.home.score, inningStats.away.score) end - local homeScore, awayScore = utils.totalScores(self.data) - drawInning(4 + inningStart + (widthAndMarg * #self.data.away), "F", homeScore, awayScore) + local homeScore, awayScore = utils.totalScores(self.stats) + drawInning(4 + inningStart + (widthAndMarg * #self.stats.innings), "F", homeScore, awayScore) + ScoreFont:drawTextAligned("v", C.Center.x, C.Screen.H - 40, gfx.kAlignCenter) +end + +local GraphM = 10 +local GraphW = C.Screen.W - (GraphM * 2) +local GraphH = C.Screen.H - (GraphM * 2) + +function BoxScore:drawScoreGraph(y) + -- TODO: Actually draw score legend + + -- Offset by 2 to support a) the zero-index b) the score legend + local segmentWidth = GraphW / (#self.stats.innings + 2) + + local legendX = segmentWidth * (#self.stats.innings + 2) - GraphM + gfx.drawLine(GraphM / 2, y + GraphM + GraphH, legendX, y + GraphM + GraphH) + gfx.drawLine(legendX, y + GraphM, legendX, y + GraphH + GraphM) + + gfx.setLineWidth(3) + local homeScore, awayScore = utils.totalScores(self.stats) + local highestScore = math.max(homeScore, awayScore) + + local heightPerPoint = (GraphH - 6) / highestScore + + function point(inning, score) + return utils.xy(GraphM + (inning * segmentWidth), y + GraphM + GraphH + (score * -heightPerPoint)) + end + + function drawLine(teamId) + local linePoints = { point(0, 0) } + local scoreTotal = 0 + for i, inning in ipairs(self.stats.innings) do + scoreTotal = scoreTotal + inning[teamId].score + linePoints[#linePoints + 1] = point(i, scoreTotal) + end + dbg.drawLine(linePoints) + local finalPoint = linePoints[#linePoints] + SmallFont:drawTextAligned(string.upper(teamId), finalPoint.x + 3, finalPoint.y - 7, gfx.kAlignRight) + end + + drawLine("home") + gfx.setDitherPattern(0.5) + drawLine("away") + gfx.setDitherPattern(0) +end + +---@param realHit XyPair +---@return XyPair +function convertHitToMini(realHit) + -- Convert to all-positive y + local y = realHit.y + C.Screen.H + y = y / 2 + + local x = realHit.x + C.Screen.W + x = x / 3 + return utils.xy(x, y) +end + +function BoxScore:drawHitChart(y) + local leftMargin = 8 + GrassBackgroundSmall:drawCentered(C.Center.x, y + C.Center.y + 54) + gfx.setLineWidth(1) + ScoreFont:drawTextAligned("AWAY", leftMargin, y + C.Screen.H - NumHeight, gfx.kAlignRight) + gfx.setColor(gfx.kColorBlack) + gfx.setDitherPattern(0.5, gfx.image.kDitherTypeBayer2x2) + gfx.fillRect(leftMargin, y + C.Screen.H - NumHeight, ScoreFont:getTextWidth("AWAY"), NumHeight) + + gfx.setColor(gfx.kColorWhite) + gfx.setDitherPattern(0.5) + for _, inning in ipairs(self.stats.innings) do + for _, hit in ipairs(inning.away.hits) do + local miniHitPos = convertHitToMini(hit) + gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4) + end + end + gfx.setColor(gfx.kColorWhite) + gfx.setDitherPattern(0) + ScoreFont:drawTextAligned(" HOME", leftMargin, y + C.Screen.H - (NumHeight * 2), gfx.kAlignRight) + for _, inning in ipairs(self.stats.innings) do + for _, hit in ipairs(inning.home.hits) do + local miniHitPos = convertHitToMini(hit) + gfx.fillCircleAtPoint(miniHitPos.x + 10, miniHitPos.y + y, 4) + end + end +end + +local screens = { + BoxScore.drawBoxScore, + BoxScore.drawScoreGraph, + BoxScore.drawHitChart, +} + +function BoxScore:render() + local originalDrawMode = gfx.getImageDrawMode() + gfx.clear(gfx.kColorBlack) + gfx.setImageDrawMode(gfx.kDrawModeInverted) + gfx.setColor(gfx.kColorBlack) + + for i, screen in ipairs(screens) do + screen(self, (i - 1) * C.Screen.H) + end + gfx.setImageDrawMode(originalDrawMode) end + +local renderedImage + +function BoxScore:update() + if not renderedImage then + renderedImage = gfx.image.new(C.Screen.W, C.Screen.H * #screens) + gfx.pushContext(renderedImage) + self:render() + gfx.popContext() + end + + local deltaSeconds = playdate.getElapsedTime() + playdate.resetElapsedTime() + + gfx.setDrawOffset(0, self.targetY) + renderedImage:draw(0, 0) + + local crankChange = playdate.getCrankChange() + if crankChange ~= 0 then + self.targetY = self.targetY - (crankChange * 0.8) + else + local closestScreen = math.floor(0.5 + (self.targetY / C.Screen.H)) * C.Screen.H + if math.abs(self.targetY - closestScreen) > 3 then + local needsIncrease = self.targetY < closestScreen + local change = needsIncrease and 200 * deltaSeconds or -200 * deltaSeconds + self.targetY = self.targetY + change + end + end + self.targetY = math.max(math.min(self.targetY, 0), -C.Screen.H * (#screens - 1)) +end diff --git a/src/images/game/GrassBackgroundSmall.png b/src/images/game/GrassBackgroundSmall.png new file mode 100644 index 0000000..d712d20 Binary files /dev/null and b/src/images/game/GrassBackgroundSmall.png differ diff --git a/src/main.lua b/src/main.lua index d10ae19..74e53a8 100644 --- a/src/main.lua +++ b/src/main.lua @@ -59,7 +59,6 @@ local teams = { }, } ----@alias BoxScoreData table ---@alias TeamId 'home' | 'away' --- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game. @@ -76,7 +75,7 @@ local teams = { ---@field catcherThrownBall boolean ---@field offenseState OffenseState ---@field inning number ----@field boxScore BoxScoreData +---@field stats Statistics ---@field batBase XyPair ---@field batTip XyPair ---@field batAngleDeg number @@ -139,10 +138,7 @@ function Game.new(settings, announcer, fielding, baserunning, npc, state) battingTeamSprites = settings.awayTeamSprites, fieldingTeamSprites = settings.homeTeamSprites, runnerBlipper = runnerBlipper, - boxScore = { - home = { 0 }, - away = { 0 }, - }, + stats = Statistics.new(), }, }, { __index = Game }) @@ -267,7 +263,8 @@ end function Game:nextHalfInning() pitchTracker:reset() - local homeScore, awayScore = utils.totalScores(self.state.boxScore) + local homeScore, awayScore = utils.totalScores(self.state.stats) + -- TODO end the game if away team just batted and home team is winning local gameOver = self.state.battingTeam == "home" and self.state.inning == self.settings.finalInning and awayScore ~= homeScore @@ -275,7 +272,7 @@ function Game:nextHalfInning() if gameOver then self.announcer:say("AND THAT'S THE BALL GAME!") playdate.timer.new(3000, function() - transitionTo(BoxScore.new(self.state.boxScore)) + transitionTo(BoxScore.new(self.state.stats)) end) return end @@ -288,8 +285,7 @@ function Game:nextHalfInning() 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 + self.state.stats:pushInning() end self.state.battingTeam = getOppositeTeamId(self.state.battingTeam) playdate.timer.new(2000, function() @@ -305,10 +301,19 @@ function Game:nextHalfInning() end) end +---@return TeamInningData +function Game:battingTeamCurrentInning() + return self.state.stats.innings[self.state.inning][self.state.battingTeam] +end + +---@return TeamInningData +function Game:fieldingTeamCurrentInning() + return self.state.stats.innings[self.state.inning][getOppositeTeamId(self.state.battingTeam)] +end + ---@param scoredRunCount number function Game:score(scoredRunCount) - local battingTeamBoxScore = self.state.boxScore[self.state.battingTeam] - battingTeamBoxScore[self.state.inning] = battingTeamBoxScore[self.state.inning] + scoredRunCount + self:battingTeamCurrentInning().score = self:battingTeamCurrentInning().score + scoredRunCount self.announcer:say("SCORE!") end @@ -405,6 +410,8 @@ function Game:updateBatting(batDeg, batSpeed) local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) 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. + local battingTeamStats = self:battingTeamCurrentInning() + battingTeamStats.hits[#battingTeamStats.hits + 1] = utils.xy(ballDestX, ballDestY) if utils.isFoulBall(ballDestX, ballDestY) then self.announcer:say("Foul ball!") @@ -412,7 +419,6 @@ function Game:updateBatting(batDeg, batSpeed) -- TODO: Have a fielder chase for the fly-out return end - self.baserunning:convertBatterToRunner() self.fielding:haveSomeoneChase(ballDestX, ballDestY) @@ -476,6 +482,13 @@ function Game:updateGameState() if self.state.secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not self.state.catcherThrownBall then local outcome = pitchTracker:updatePitchCounts() + local currentPitchingStats = self:fieldingTeamCurrentInning().pitching + if outcome == PitchOutcomes.Strike or outcome == PitchOutcomes.StrikeOut then + currentPitchingStats.strikes = currentPitchingStats.strikes + 1 + end + if outcome == PitchOutcomes.Ball or outcome == PitchOutcomes.Walk then + currentPitchingStats.balls = currentPitchingStats.balls + 1 + end if outcome == PitchOutcomes.StrikeOut then self:strikeOut() elseif outcome == PitchOutcomes.Walk then @@ -638,7 +651,7 @@ function Game:update() if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then drawMinimap(self.baserunning.runners, self.fielding.fielders) end - local homeScore, awayScore = utils.totalScores(self.state.boxScore) + local homeScore, awayScore = utils.totalScores(self.state.stats) drawScoreboard( 0, C.Screen.H * 0.77, diff --git a/src/utils.lua b/src/utils.lua index 7f55c63..f010ce0 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -232,24 +232,23 @@ function utils.getNearestOf(array, x, y, extraCondition) return nearest, nearestDistance end ----@param box BoxScore +---@param stats Statistics ---@return number homeScore, number awayScore -function utils.totalScores(box) +function utils.totalScores(stats) 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 + for _, inning in pairs(stats.innings) do + homeScore = homeScore + inning.home.score + awayScore = awayScore + inning.away.score end return homeScore, awayScore end PitchOutcomes = { + Strike = {}, StrikeOut = {}, + Ball = {}, Walk = {}, }