diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..262b529 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,4 @@ +std = "lua54+playdate" +stds.project = { + read_globals = {"playdate"} +} diff --git a/Makefile b/Makefile index 4f2c077..fe792ce 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,3 @@ -SOURCE_FILES := $(shell grep "import '" src/main.lua | grep -v CoreLibs | sed "s/.*'\(.*\)'.*/src\/\1/") src/main.lua - all: pdc --skip-unknown src BatterUp.pdx @@ -8,7 +6,7 @@ assets: check: assets stylua -c --indent-type Spaces src/ - cat __stub.ext.lua <(sed 's/^function/-- selene: allow(unused_variable)\nfunction/' ${PLAYDATE_SDK_PATH}/CoreLibs/__types.lua) ${SOURCE_FILES} | grep -v '^import' | sed 's///g' | selene - + luacheck -d --codes src/ --exclude-files src/test/ test: check (cd src; find ./test -name '*lua' | xargs -L1 lua) diff --git a/src/action-queue.lua b/src/action-queue.lua index 2cfcb5e..2571249 100644 --- a/src/action-queue.lua +++ b/src/action-queue.lua @@ -1,36 +1,26 @@ ----@alias ActionResult {} - ----@type table --- selene: allow(unscoped_variables) -ActionResult = { - Succeeded = {}, - Failed = {}, - NeedsMoreTime = {}, -} - --- selene: allow(unscoped_variables) actionQueue = { - ---@type ({ action: Action, expireTimeMs: number })[] + ---@type table queue = {}, } ----@alias Action fun(deltaSeconds: number): ActionResult +---@alias Action fun(deltaSeconds: number) + +local close = coroutine.close --- Added actions will be called on every runWaiting() update. --- They will continue to be executed until they return Succeeded or Failed instead of NeedsMoreTime. --- ---- Replaces any existing action with the given name. +--- Replaces any existing action with the given id. --- If the initial call of action() doesn't return NeedsMoreTime, this function will not bother adding it to the queue. ----@param name string +---@param id any ---@param maxTimeMs number ---@param action Action -function actionQueue:upsert(name, maxTimeMs, action) - if action(0) ~= ActionResult.NeedsMoreTime then - return +function actionQueue:upsert(id, maxTimeMs, action) + if self.queue[id] then + close(self.queue[id].coroutine) end - - self.queue[name] = { - action = action, + self.queue[id] = { + coroutine = coroutine.create(action), expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(), } end @@ -39,10 +29,16 @@ end --- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired. function actionQueue:runWaiting(deltaSeconds) local currentTimeMs = playdate.getCurrentTimeMilliseconds() - for name, actionObject in pairs(self.queue) do - local result = actionObject.action(deltaSeconds) - if result ~= ActionResult.NeedsMoreTime or currentTimeMs > actionObject.expireTimeMs then - self.queue[name] = nil + + for id, actionObject in pairs(self.queue) do + coroutine.resume(actionObject.coroutine, deltaSeconds) + + if currentTimeMs > actionObject.expireTimeMs then + close(actionObject.coroutine) + end + + if coroutine.status(actionObject.coroutine) == "dead" then + self.queue[id] = nil end end end diff --git a/src/announcer.lua b/src/announcer.lua index d72458b..fda410d 100644 --- a/src/announcer.lua +++ b/src/announcer.lua @@ -1,4 +1,3 @@ --- selene: allow(shadowing) local gfx = playdate.graphics local AnnouncementFont = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft") @@ -10,7 +9,6 @@ local AnnouncerAnimatorInY = local AnnouncerAnimatorOutY = playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint) --- selene: allow(unscoped_variables) ---@class Announcer ---@field textQueue string[] ---@field animatorY pd_animator @@ -46,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 diff --git a/src/assets.lua b/src/assets.lua index 4ff3650..ed4e298 100644 --- a/src/assets.lua +++ b/src/assets.lua @@ -1,86 +1,67 @@ -- GENERATED FILE - DO NOT EDIT -- Instead, edit the source file directly: assets.lua2p. ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore Glove = playdate.graphics.image.new("images/game/Glove.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore +BigBat = playdate.graphics.image.new("images/game/BigBat.png") +-- luacheck: ignore GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore +GameLogo = playdate.graphics.image.new("images/game/GameLogo.png") +-- luacheck: ignore Hat = playdate.graphics.image.new("images/game/Hat.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore DarkPlayerBase = playdate.graphics.image.new("images/game/DarkPlayerBase.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore MenuImage = playdate.graphics.image.new("images/game/MenuImage.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore PlayerSmile = playdate.graphics.image.new("images/game/PlayerSmile.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore Minimap = playdate.graphics.image.new("images/game/Minimap.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore +GrassBackgroundSmall = playdate.graphics.image.new("images/game/GrassBackgroundSmall.png") +-- luacheck: ignore LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) +-- luacheck: ignore TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav") +-- luacheck: ignore +MenuMusic = playdate.sound.sampleplayer.new("music/MenuMusic.wav") ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) Logos = { ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Cats = playdate.graphics.image.new("images/game/logos/Cats.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Hearts = playdate.graphics.image.new("images/game/logos/Hearts.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Checkmarks = playdate.graphics.image.new("images/game/logos/Checkmarks.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Smiles = playdate.graphics.image.new("images/game/logos/Smiles.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -FingerGuns = playdate.graphics.image.new("images/game/logos/FingerGuns.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Base = playdate.graphics.image.new("images/game/logos/Base.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Frown = playdate.graphics.image.new("images/game/logos/Frown.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Arrows = playdate.graphics.image.new("images/game/logos/Arrows.png"), ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) -Turds = playdate.graphics.image.new("images/game/logos/Turds.png"), +{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") }, + +-- luacheck: ignore +{ name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") }, +-- luacheck: ignore +{ name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") }, +-- luacheck: ignore +{ name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") }, +-- luacheck: ignore +{ name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") }, +-- luacheck: ignore +{ name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") }, +-- luacheck: ignore +{ name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") }, +-- luacheck: ignore +{ name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") }, +-- luacheck: ignore +{ name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.png") }, } diff --git a/src/assets.lua2p b/src/assets.lua2p index 431b745..a589b91 100644 --- a/src/assets.lua2p +++ b/src/assets.lua2p @@ -1,7 +1,10 @@ -!(function dirLookup(dir, extension, newFunc, sep) +!(function dirLookup(dir, extension, newFunc, sep, handle) sep = sep or "\n" + handle = handle ~= nil and handle or function(varName, value) + return varName .. ' = ' .. value + end --Open directory look for files, save data in p. By giving '-type f' as parameter, it returns all files. - local p = io.popen('find src/' .. dir .. ' -type f -maxdepth 1') + local p = io.popen('find src/' .. dir .. ' -maxdepth 1 -type f') local assetCode = "" --Loop through all files @@ -9,9 +12,8 @@ if file:find(extension) then local varName = file:gsub(".*/(.*)." .. extension, "%1") file = file:gsub("src/", "") - assetCode = assetCode .. '--selene: allow(unused_variable)\n' - assetCode = assetCode .. '--selene: allow(unscoped_variables)\n' - assetCode = assetCode .. varName .. ' = ' .. newFunc .. '("' .. file .. '")' .. sep + assetCode = assetCode .. '-- luacheck: ignore\n' + assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep end end return assetCode @@ -24,8 +26,10 @@ end)!!(generatedFileWarning()) !!(dirLookup('images/game', 'png', 'playdate.graphics.image.new')) !!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new')) !!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new')) ---selene: allow(unused_variable) ---selene: allow(unscoped_variables) Logos = { -!!(dirLookup('images/game/logos', 'png', 'playdate.graphics.image.new', ",\n")) +{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") }, + +!!(dirLookup('images/game/logos -not -name "Base.png"', 'png', 'playdate.graphics.image.new', ",\n", function(varName, value) + return '{ name = "' .. varName .. '", image = ' .. value .. ' }' +end)) } diff --git a/src/ball.lua b/src/ball.lua index 9b7016c..392b5b1 100644 --- a/src/ball.lua +++ b/src/ball.lua @@ -1,16 +1,15 @@ --- selene: allow(unscoped_variables) ---@class Ball ---@field x number ---@field y number ---@field z number ---@field size number ---@field heldBy Fielder | nil +---@field catchable boolean ---@field xAnimator SimpleAnimator ---@field yAnimator SimpleAnimator ---@field sizeAnimator SimpleAnimator ---@field floatAnimator SimpleAnimator ---@field private animatorLib pd_animator_lib ----@field private flyTimeMs number Ball = {} ---@param animatorLib pd_animator_lib @@ -21,6 +20,7 @@ function Ball.new(animatorLib) x = C.Center.x --[[@as number]], y = C.Center.y --[[@as number]], z = 0, + catchable = true, size = C.SmallestBallRadius, heldBy = nil --[[@type Runner | nil]], @@ -50,6 +50,13 @@ function Ball:updatePosition() end end +function Ball:markUncatchable() + self.catchable = false + playdate.timer.new(200, function() + self.catchable = true + end) +end + --- Launches the ball from its current position to the given destination. ---@param destX number ---@param destY number @@ -58,18 +65,19 @@ end ---@param floaty boolean | nil ---@param customBallScaler pd_animator | nil function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) + throwMeter:reset() self.heldBy = nil + -- Prevent silly insta-catches + self:markUncatchable() + if not flyTimeMs then flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower end - self.flyTimeMs = flyTimeMs - if customBallScaler then self.sizeAnimator = customBallScaler else - -- TODO? Scale based on distance? self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill) end self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc) diff --git a/src/baserunning.lua b/src/baserunning.lua index 6f5b3cb..ebb9168 100644 --- a/src/baserunning.lua +++ b/src/baserunning.lua @@ -6,18 +6,20 @@ --- forcedTo: Base | nil, --- } --- selene: allow(unscoped_variables) ---@class Baserunning ---@field runners Runner[] ---@field outRunners Runner[] ---@field scoredRunners Runner[] ---@field batter Runner | nil ---@field outs number +-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0 +---@field secondsSinceLastRunnerMove number ---@field announcer Announcer ---@field onThirdOut fun() Baserunning = {} --- TODO: Implement slides. Would require making fielders' gloves "real objects" whose state is tracked. +-- TODO: Implement slides? Would require making fielders' gloves "real objects" whose state is tracked. +-- TODO: Don't allow runners to occupy the same base! ---@param announcer Announcer ---@return Baserunning @@ -39,7 +41,7 @@ function Baserunning.new(announcer, onThirdOut) return o end ----@param runner integer | Runner +---@param runner number | Runner ---@param message string | nil ---@return boolean wasThirdOut function Baserunning:outRunner(runner, message) @@ -66,7 +68,6 @@ function Baserunning:outRunner(runner, message) self.onThirdOut() self.outs = 0 - -- TODO: outRunners/scoredRunners split while #self.runners > 0 do self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners) end @@ -125,16 +126,27 @@ function Baserunning:convertBatterToRunner() self.batter = nil -- Demote batter to a mere runner end +local function walkWayOutRunner(deltaSeconds, runner) + if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then + runner.x = runner.x + (deltaSeconds * 25) + runner.y = runner.y + (deltaSeconds * 25) + return true + end + return false +end + ---@param deltaSeconds number function Baserunning:walkAwayOutRunners(deltaSeconds) for i, runner in ipairs(self.outRunners) do - if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then - runner.x = runner.x + (deltaSeconds * 25) - runner.y = runner.y + (deltaSeconds * 25) - else + if not walkWayOutRunner(deltaSeconds, runner) then table.remove(self.outRunners, i) end end + for i, runner in ipairs(self.scoredRunners) do + if not walkWayOutRunner(deltaSeconds, runner) then + table.remove(self.scoredRunners, i) + end + end end ---@return Runner @@ -153,16 +165,16 @@ function Baserunning:pushNewBatter() end ---@param self table ----@param runnerIndex integer +---@param runnerIndex number function Baserunning:runnerScored(runnerIndex) -- TODO: outRunners/scoredRunners split - self.outRunners[#self.outRunners + 1] = self.runners[runnerIndex] + self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex] table.remove(self.runners, runnerIndex) end --- Returns true only if the given runner moved during this update. ---@param runner Runner | nil ----@param runnerIndex integer | nil May only be nil if runner == batter +---@param runnerIndex number | nil May only be nil if runner == batter ---@param appliedSpeed number ---@param deltaSeconds number ---@return boolean runnerMoved, boolean runnerScored @@ -227,9 +239,9 @@ end --- Update non-batter runners. --- Returns true only if at least one of the given runners moved during this update ---@param appliedSpeed number | fun(runner: Runner): number ----@return boolean someRunnerMoved, number runnersScored +---@return boolean runnersStillMoving, number runnersScored, number secondsSinceLastMove function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) - local someRunnerMoved = false + local runnersStillMoving = false local runnersScored = 0 local speedIsFunction = type(appliedSpeed) == "function" @@ -241,20 +253,24 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon speed = appliedSpeed(runner) end local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds) - someRunnerMoved = someRunnerMoved or thisRunnerMoved + runnersStillMoving = runnersStillMoving or thisRunnerMoved if thisRunnerScored then runnersScored = runnersScored + 1 end end end - if someRunnerMoved then + if runnersStillMoving then + self.secondsSinceLastRunnerMove = 0 self:updateForcedRunners() + else + self.secondsSinceLastRunnerMove = (self.secondsSinceLastRunnerMove or 0) + deltaSeconds end - return someRunnerMoved, runnersScored + return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove end +-- luacheck: ignore if not playdate or playdate.TEST_MODE then return Baserunning end diff --git a/src/constants.lua b/src/constants.lua index a95877e..a237178 100644 --- a/src/constants.lua +++ b/src/constants.lua @@ -1,4 +1,3 @@ --- selene: allow(unscoped_variables) C = {} C.Screen = { @@ -31,7 +30,7 @@ C.FieldHeight = C.Bases[C.Home].y - C.Bases[C.Second].y -- Pseudo-base for batter to target C.RightHandedBattersBox = { x = C.Bases[C.Home].x - 30, - y = C.Bases[C.Home].y, + y = C.Bases[C.Home].y + 10, } ---@type table @@ -71,8 +70,8 @@ C.BallOffscreen = 999 C.PitchAfterSeconds = 6 C.ReturnToPitcherAfterSeconds = 2.4 C.PitchFlyMs = 1050 -C.PitchStartX = 195 -C.PitchStartY, C.PitchEndY = 105, 240 +C.PitchStart = utils.xy(195, 105) +C.PitchEndY = 240 C.DefaultLaunchPower = 4 @@ -92,33 +91,23 @@ C.SmallestBallRadius = 6 C.BatLength = 35 --- TODO: enums implemented this way are probably going to be difficult to serialize! - ----@alias OffenseState table +---@alias OffenseState "batting" | "running" | "walking" | "homeRun" --- An enum for what state the offense is in ---@type table C.Offense = { - batting = {}, - running = {}, - walking = {}, + batting = "batting", + running = "running", + walking = "walking", + homeRun = "homeRun", } ----@alias Side table ---- An enum for which side (offense or defense) a team is on. ----@type table -C.Sides = { - offense = {}, - defense = {}, -} +---@alias Side "offense" | "defense" C.PitcherStartPos = { x = C.Screen.W * 0.48, y = C.Screen.H * 0.40, } -C.ThrowMeterMax = 10 -C.ThrowMeterDrainPerSec = 150 - --- Controls how hard the ball can be hit, and --- how fast the ball can be thrown. C.CrankPower = 10 @@ -132,6 +121,24 @@ C.WalkedRunnerSpeed = 10 C.ResetFieldersAfterSeconds = 2.5 +C.OutfieldWall = { + { x = -400, y = -103 }, + { x = -167, y = -208 }, + { x = 50, y = -211 }, + { x = 150, y = -181 }, + { x = 339, y = -176 }, + { x = 450, y = -221 }, + { x = 700, y = -209 }, + { x = 785, y = -59 }, + { x = 801, y = -16 }, +} + +C.BottomOfOutfieldWall = {} + +for i, v in ipairs(C.OutfieldWall) do + C.BottomOfOutfieldWall[i] = utils.xy(v.x, v.y + 40) +end + if not playdate then return C end diff --git a/src/dbg.lua b/src/dbg.lua index 460f400..7005789 100644 --- a/src/dbg.lua +++ b/src/dbg.lua @@ -1,7 +1,5 @@ --- selene: allow(unscoped_variables) dbg = {} --- selene: allow(unused_variable) function dbg.label(value, name) if type(value) == "table" then print(name .. ":") @@ -19,7 +17,6 @@ function dbg.label(value, name) end -- Only works if called with the bases empty (i.e. the only runner should be the batter. --- selene: allow(unused_variable) function dbg.loadTheBases(br) br:pushNewBatter() br:pushNewBatter() @@ -38,6 +35,76 @@ function dbg.loadTheBases(br) br.runners[4].nextBase = C.Bases[C.Home] end +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 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 + + 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) + for i = 2, #points do + local prev = points[i - 1] + local next = points[i] + playdate.graphics.drawLine(prev.x, prev.y, next.x, next.y) + end +end + if not playdate then return dbg end diff --git a/src/draw/box-score.lua b/src/draw/box-score.lua new file mode 100644 index 0000000..f6fcced --- /dev/null +++ b/src/draw/box-score.lua @@ -0,0 +1,248 @@ +---@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 stats Statistics +---@field private targetY number +BoxScore = {} + +---@param stats Statistics +function BoxScore.new(stats) + return setmetatable({ + stats = stats, + targetY = 0, + }, { __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 = 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() +local AwayWidth = ScoreFont:getTextWidth("AWAY") +local InningMargin = 4 + +local InningDrawWidth = (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 = -4 + (NumHeight * 2) + MarginY +local AwayY = -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: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.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.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/draw/fielder.lua b/src/draw/fielder.lua index 39ff001..b9949ec 100644 --- a/src/draw/fielder.lua +++ b/src/draw/fielder.lua @@ -5,14 +5,14 @@ local GloveOffX, GloveOffY = GloveSizeX / 2, GloveSizeY / 2 ---@param fielderX number ---@param fielderY number ---@return boolean isHoldingBall -local function drawFielderGlove(ball, fielderX, fielderY) +local function drawFielderGlove(ball, fielderX, fielderY, flip) local distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z) local shoulderX, shoulderY = fielderX + 10, fielderY - 5 if distanceFromBall > 20 then - Glove:draw(shoulderX, shoulderY) + Glove:draw(shoulderX, shoulderY, flip) return false else - GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY) + GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip) return true end end @@ -22,7 +22,7 @@ end ---@param x number ---@param y number ---@return boolean isHoldingBall -function drawFielder(playerSprites, ball, x, y) - playerSprites.smiling:draw(x, y - 20) +function drawFielder(playerSprites, ball, x, y, flip) + playerSprites.smiling:draw(x, y - 20, flip) return drawFielderGlove(ball, x, y) end diff --git a/src/draw/overlay.lua b/src/draw/overlay.lua index a1288db..cfa5c4a 100644 --- a/src/draw/overlay.lua +++ b/src/draw/overlay.lua @@ -83,11 +83,10 @@ local ScoreboardHeight = 55 local Indicator = "> " local IndicatorWidth = 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 diff --git a/src/draw/panner.lua b/src/draw/panner.lua new file mode 100644 index 0000000..20841d2 --- /dev/null +++ b/src/draw/panner.lua @@ -0,0 +1,52 @@ +---@class Panner +Panner = {} + +local function panCoroutine(ball) + local offset = utils.xy(getDrawOffset(ball.x, ball.y)) + while true do + local target, deltaSeconds = coroutine.yield(offset.x, offset.y) + if target == nil then + offset = utils.xy(getDrawOffset(ball.x, ball.y)) + else + while utils.moveAtSpeed(offset, 200 * deltaSeconds, target, 20) do + target, deltaSeconds = coroutine.yield(offset.x, offset.y) + end + -- -- Pan back to ball + -- while utils.moveAtSpeed(offset, 200 * deltaSeconds, ball, 20) do + -- target, deltaSeconds = coroutine.yield(offset.x, offset.y) + -- end + end + end +end + +---@param ball XyPair +function Panner.new(ball) + return setmetatable({ + coroutine = coroutine.create(function() + panCoroutine(ball) + end), + panTarget = nil, + }, { __index = Panner }) +end + +---@param deltaSeconds number +---@return number offsetX, number offsetY +function Panner:get(deltaSeconds) + if self.holdUntil and self.holdUntil() then + self:reset() + end + local _, offsetX, offsetY = coroutine.resume(self.coroutine, self.panTarget, deltaSeconds) + return offsetX, offsetY +end + +---@param panTarget XyPair +---@param holdUntil fun(): boolean +function Panner:panTo(panTarget, holdUntil) + self.panTarget = panTarget + self.holdUntil = holdUntil +end + +function Panner:reset() + self.holdUntil = nil + self.panTarget = nil +end diff --git a/src/draw/player.lua b/src/draw/player.lua index bde0fdc..6426081 100644 --- a/src/draw/player.lua +++ b/src/draw/player.lua @@ -15,6 +15,7 @@ function maybeDrawInverted(image, x, y, drawInverted) gfx.setImageDrawMode(drawMode) end +--- TODO: Custom names on jerseys? ---@return SpriteCollection ---@param base pd_image ---@param isDark boolean @@ -52,7 +53,20 @@ function buildCollection(base, back, logo, isDark) end --selene: allow(unscoped_variables) -AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, Logos.Base, true) +---@type SpriteCollection +AwayTeamSprites = nil --selene: allow(unscoped_variables) -HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, Logos.Frown, false) +---@type SpriteCollection +HomeTeamSprites = nil + +function replaceAwayLogo(logo) + AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, logo, true) +end + +function replaceHomeLogo(logo) + HomeTeamSprites = buildCollection(LightPlayerBase, LightPlayerBack, logo, false) +end + +replaceAwayLogo(Logos[1].image) +replaceHomeLogo(Logos[2].image) diff --git a/src/draw/throw-meter.lua b/src/draw/throw-meter.lua new file mode 100644 index 0000000..13ae750 --- /dev/null +++ b/src/draw/throw-meter.lua @@ -0,0 +1,38 @@ +---@type pd_graphics_lib +local gfx = playdate.graphics + +local ThrowMeterHeight = 50 +local ThrowMeterLingerSec = 1.5 + +---@param x number +---@param y number +function throwMeter:draw(x, y) + gfx.setLineWidth(1) + gfx.drawRect(x, y, 14, ThrowMeterHeight) + if self.lastReadThrow then + -- TODO: If ratio is "perfect", show some additional effect + -- TODO: If meter has moved to a new fielder, empty it. + local ratio = (self.lastReadThrow - throwMeter.MinCharge) / (self.idealPower - throwMeter.MinCharge) + local height = ThrowMeterHeight * ratio + gfx.fillRect(x + 2, y + ThrowMeterHeight - height, 10, height) + end + -- TODO: Dither or bend if the user throws too hard + -- Or maybe dither if it's too soft - bend if it's too hard +end + +function throwMeter:drawNearFielder(fielder) + if not fielder and not self.lastThrower then + return + end + if fielder then + self.lastThrower = fielder + actionQueue:upsert("throwMeterLinger", 200 + ThrowMeterLingerSec * 1000, function() + local dt = 0 + while dt < ThrowMeterLingerSec do + dt = dt + coroutine.yield() + end + self.lastThrower = nil + end) + end + self:draw(self.lastThrower.x - 25, self.lastThrower.y - 10) +end diff --git a/src/draw/transitions.lua b/src/draw/transitions.lua new file mode 100644 index 0000000..a7ca79e --- /dev/null +++ b/src/draw/transitions.lua @@ -0,0 +1,92 @@ +Transitions = { + ---@type Scene | nil + nextScene = nil, + ---@type Scene | nil + previousScene = nil, +} + +local gfx = playdate.graphics + +local previousSceneImage +local previousSceneMask +local nextSceneImage + +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 diff --git a/src/fielding.lua b/src/fielding.lua index 30258fd..f3d1d1d 100644 --- a/src/fielding.lua +++ b/src/fielding.lua @@ -1,19 +1,26 @@ --- @class Fielder { ---- @field catchEligible boolean --- @field x number --- @field y number --- @field target XyPair | nil --- @field speed number --- TODO: Run down baserunners in a pickle. +---@class Fielders +---@field first Fielder +---@field second Fielder +---@field shortstop Fielder +---@field third Fielder +---@field pitcher Fielder +---@field catcher Fielder +---@field left Fielder +---@field center Fielder +---@field right Fielder --- selene: allow(unscoped_variables) ---@class Fielding ----@field fielders table +---@field fielders Fielders ---@field fielderHoldingBall Fielder | nil Fielding = {} -local FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill) +FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1 ---@param name string @@ -23,7 +30,6 @@ local function newFielder(name, speed) return { name = name, speed = speed * C.FielderRunMult, - catchEligible = true, } end @@ -79,45 +85,54 @@ end ---@param deltaSeconds number ---@param fielder Fielder ----@param ballPos XyPair ----@return boolean inCatchingRange -local function updateFielderPosition(deltaSeconds, fielder, ballPos) +---@param ball Ball +---@return boolean canCatch +local function updateFielderPosition(deltaSeconds, fielder, ball) if fielder.target ~= nil then - if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then + if + utils.pointIsSquarelyAboveLine(fielder, C.BottomOfOutfieldWall) + or not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) + then fielder.target = nil end end - return utils.distanceBetweenPoints(fielder, ballPos) < C.BallCatchHitbox + return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox end +-- TODO: Prevent multiple fielders covering the same base. +-- At least in a how-about-everybody-stand-right-here way. + --- Selects the nearest fielder to move toward the given coordinates. --- Other fielders should attempt to cover their bases ----@param self table ---@param ballDestX number ---@param ballDestY number function Fielding:haveSomeoneChase(ballDestX, ballDestY) local chasingFielder = utils.getNearestOf(self.fielders, ballDestX, ballDestY) - chasingFielder.target = { x = ballDestX, y = ballDestY } + chasingFielder.target = utils.xy(ballDestX, ballDestY) for _, base in ipairs(C.Bases) do local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder) - -- For now, skipping the pitcher because they're considered closer to 2B than second or shortstop - return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher + -- Skip the pitcher for 2B - they're considered closer than second or shortstop. + if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then + return false + end + return fielder ~= chasingFielder end) nearest.target = base end end +--- **Also updates `ball.heldby`** ---@param ball Ball ---@param deltaSeconds number ---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball function Fielding:updateFielderPositions(ball, deltaSeconds) - local fielderHoldingBall = nil + local fielderHoldingBall for _, fielder in pairs(self.fielders) do - local inCatchingRange = updateFielderPosition(deltaSeconds, fielder, ball) - if inCatchingRange and fielder.catchEligible then - -- TODO: Base this catch on fielder skill? + -- TODO: Base this catch on fielder skill? + local canCatch = updateFielderPosition(deltaSeconds, fielder, ball) + if canCatch then fielderHoldingBall = fielder ball.heldBy = fielder -- How much havoc will this wreak? end @@ -129,73 +144,45 @@ function Fielding:updateFielderPositions(ball, deltaSeconds) return fielderHoldingBall end ----@param fielder Fielder -function Fielding.markIneligible(fielder) - fielder.catchEligible = false - playdate.timer.new(500, function() - fielder.catchEligible = true - end) -end - -function Fielding:markAllIneligible() - for _, fielder in pairs(self.fielders) do - fielder.catchEligible = false - end - playdate.timer.new(750, function() - for _, fielder in pairs(self.fielders) do - fielder.catchEligible = true - end - end) -end - -- TODO? Start moving target fielders close sooner? ---@param field Fielding ---@param targetBase Base ----@param launchBall LaunchBall +---@param ball { launch: LaunchBall } ---@param throwFlyMs number ----@return ActionResult -local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs) - if field.fielderHoldingBall == nil then - return ActionResult.NeedsMoreTime - end - local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder) - return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing - end) +local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs) + while true do + if field.fielderHoldingBall == nil then + coroutine.yield() + else + local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder) + return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing + end) - closestFielder.target = targetBase - launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) - Fielding.markIneligible(field.fielderHoldingBall) - return ActionResult.Succeeded + closestFielder.target = targetBase + ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) + + return + end + end 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() - return userThrowToImpl(self, targetBase, launchBall, throwFlyMs) + userThrowToCoroutine(self, targetBase, ball, throwFlyMs) end) end -function Fielding:celebrate() +function Fielding.celebrate() FielderDanceAnimator:reset(C.DanceBounceMs) end ----@param fielderSprites SpriteCollection ----@param ball Point3d ----@return boolean ballIsHeldByAFielder -function Fielding:drawFielders(fielderSprites, ball) - local ballIsHeld = false - local danceOffset = FielderDanceAnimator:currentValue() - for _, fielder in pairs(self.fielders) do - ballIsHeld = drawFielder(fielderSprites, ball, fielder.x, fielder.y + danceOffset) or ballIsHeld - end - return ballIsHeld -end - +-- luacheck: ignore if not playdate or playdate.TEST_MODE then return { Fielding, newFielder } end diff --git a/src/fonts/Asheville-Sans-14-Bold-table-20-20.png b/src/fonts/Asheville-Sans-14-Bold-table-20-20.png new file mode 100755 index 0000000..2b3c6a8 Binary files /dev/null and b/src/fonts/Asheville-Sans-14-Bold-table-20-20.png differ diff --git a/src/fonts/Asheville-Sans-14-Bold.fnt b/src/fonts/Asheville-Sans-14-Bold.fnt new file mode 100755 index 0000000..3ee7fa3 --- /dev/null +++ b/src/fonts/Asheville-Sans-14-Bold.fnt @@ -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 +� 13 \ No newline at end of file diff --git a/src/fonts/Roobert-20-Medium-table-32-32.png b/src/fonts/Roobert-20-Medium-table-32-32.png index 0e7b41b..fad1a25 100644 Binary files a/src/fonts/Roobert-20-Medium-table-32-32.png and b/src/fonts/Roobert-20-Medium-table-32-32.png differ diff --git a/src/graphics.lua b/src/graphics.lua index 9776473..7bd8479 100644 --- a/src/graphics.lua +++ b/src/graphics.lua @@ -7,7 +7,8 @@ function getDrawOffset(ballX, ballY) if ballY > C.Screen.H or ballX >= C.BallOffscreen then return 0, 0 end - offsetY = math.max(0, -1 * ballY) + -- Keep the ball approximately in the center, once it's past C.Center.y - 30 + offsetY = math.max(0, (-1 * ballY) + C.Center.y - 30) if ballX > 0 and ballX < C.Screen.W then offsetX = 0 @@ -17,10 +18,11 @@ function getDrawOffset(ballX, ballY) offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W) end - return offsetX * 1.3, offsetY * 1.5 + return offsetX * 1.3, offsetY end --- selene: allow(unscoped_variables) +---@class Blipper +---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number) blipper = {} --- Build an object that simply "blips" between the given images at the given interval. @@ -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) diff --git a/src/images/game/BigBat.png b/src/images/game/BigBat.png new file mode 100644 index 0000000..038da28 Binary files /dev/null and b/src/images/game/BigBat.png differ diff --git a/src/images/game/GameLogo.png b/src/images/game/GameLogo.png new file mode 100644 index 0000000..3c231b8 Binary files /dev/null and b/src/images/game/GameLogo.png differ diff --git a/src/images/game/GrassBackground.png b/src/images/game/GrassBackground.png index a33353f..00bae54 100644 Binary files a/src/images/game/GrassBackground.png and b/src/images/game/GrassBackground.png differ 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/images/launcher/wrapping-pattern.png b/src/images/launcher/wrapping-pattern.png new file mode 100644 index 0000000..54f73c7 Binary files /dev/null and b/src/images/launcher/wrapping-pattern.png differ diff --git a/src/main-menu.lua b/src/main-menu.lua new file mode 100644 index 0000000..4f244c4 --- /dev/null +++ b/src/main-menu.lua @@ -0,0 +1,121 @@ +---@class MainMenu +MainMenu = { + ---@type { new: fun(settings: Settings): { update: fun(self) } } + next = nil, +} +local gfx = playdate.graphics + +local StartFont = gfx.font.new("fonts/Roobert-20-Medium.pft") + +--- Take control of playdate.update +--- Will replace playdate.update when the menu is done. +---@param next { new: fun(settings: Settings): { update: fun(self) } } +function MainMenu.start(next) + MenuMusic:play(0) + MainMenu.next = next + playdate.update = MainMenu.update +end + +local inningCountSelection = 3 + +local function startGame() + local next = MainMenu.next.new({ + finalInning = inningCountSelection, + homeTeamSprites = HomeTeamSprites, + awayTeamSprites = AwayTeamSprites, + }) + playdate.resetElapsedTime() + transitionBetween(MainMenu, next) + MenuMusic:setPaused(true) +end + +local function pausingEaser(baseEaser) + --- t: elapsedTime + --- d: duration + return function(t, b, c, d) + local percDone = t / d + if percDone > 0.9 then + t = d + elseif percDone < 0.1 then + t = 0 + else + t = (percDone - 0.1) * 1.25 * d + end + return baseEaser(t, b, c, d) + end +end + +local animatorX = gfx.animator.new(2000, 30, 350, pausingEaser(playdate.easingFunctions.linear)) +animatorX.repeatCount = -1 +animatorX.reverses = true + +local animatorY = gfx.animator.new(2000, 60, 200, pausingEaser(utils.easingHill)) +animatorY.repeatCount = -1 +animatorY.reverses = true + +local crankStartPos = nil + +---@generic T +---@param array T[] +---@param crankPosition number +---@return T +local function arrayElementFromCrank(array, crankPosition) + local i = math.ceil(#array * (crankPosition + 0.001) / 360) + return array[i] +end + +local currentLogo + +--luacheck: ignore +function MainMenu:update() + playdate.timer.updateTimers() + crankStartPos = crankStartPos or playdate.getCrankPosition() + + gfx.clear() + + if playdate.getCrankChange() ~= 0 then + local crankOffset = (crankStartPos - playdate.getCrankPosition()) % 360 + currentLogo = arrayElementFromCrank(Logos, crankOffset).image + replaceAwayLogo(currentLogo) + end + + if currentLogo then + currentLogo:drawScaled(20, C.Center.y + 40, 3) + end + + if playdate.buttonJustPressed(playdate.kButtonA) then + startGame() + end + if playdate.buttonJustPressed(playdate.kButtonUp) then + inningCountSelection = inningCountSelection + 1 + end + if playdate.buttonJustPressed(playdate.kButtonDown) then + inningCountSelection = inningCountSelection - 1 + end + + GameLogo:drawCentered(C.Center.x, 50) + + StartFont:drawTextAligned("Press A to start!", C.Center.x, 140, kTextAlignment.center) + gfx.drawTextAligned("with " .. inningCountSelection .. " innings", C.Center.x, 190, kTextAlignment.center) + + local ball = { + x = animatorX:currentValue(), + y = animatorY:currentValue(), + z = 6, + size = 6, + } + + local ballIsHeld = drawFielder(AwayTeamSprites, ball, 30, 200) + ballIsHeld = drawFielder(HomeTeamSprites, ball, 350, 200, playdate.graphics.kImageFlippedX) or ballIsHeld + + -- drawFielder(AwayTeamSprites, { x = 0, y = 0, z = 0 }, ball.x, ball.y) + if not ballIsHeld then + gfx.setLineWidth(2) + + gfx.setColor(gfx.kColorWhite) + gfx.fillCircleAtPoint(ball.x, ball.y, ball.size) + + gfx.setColor(gfx.kColorBlack) + gfx.drawCircleAtPoint(ball.x, ball.y, ball.size) + end +end diff --git a/src/main.lua b/src/main.lua index b42fc8a..9da90a6 100644 --- a/src/main.lua +++ b/src/main.lua @@ -8,9 +8,12 @@ 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( +--- self: self, --- destX: number, --- destY: number, --- easingFunc: EasingFunc, @@ -23,9 +26,7 @@ import 'CoreLibs/ui.lua' import 'utils.lua' import 'constants.lua' import 'assets.lua' -import 'draw/player.lua' -import 'draw/overlay.lua' -import 'draw/fielder.lua' +import 'main-menu.lua' import 'action-queue.lua' import 'announcer.lua' @@ -35,190 +36,268 @@ import 'dbg.lua' import 'fielding.lua' import 'graphics.lua' import 'npc.lua' +import 'pitching.lua' + +import 'draw/box-score.lua' +import 'draw/fielder.lua' +import 'draw/overlay.lua' +import 'draw/panner.lua' +import 'draw/player.lua' +import 'draw/throw-meter.lua' +import 'draw/transitions.lua' -- stylua: ignore end --- selene: allow(shadowing) +-- TODO: Customizable field structure. E.g. stands and ads etc. + local gfx , C = playdate.graphics, C -local announcer = Announcer.new() -local fielding = Fielding.new() --- TODO: Find a way to get baserunning and npc instantiated closer to the top, here. --- Currently difficult because they depend on nextHalfInning/each other. - ------------------- --- GLOBAL STATE -- ------------------- - -local deltaSeconds = 0 -local ball = Ball.new(gfx.animator) - -local batBase = utils.xy(C.Center.x - 34, 215) -local batTip = utils.xy(0, 0) -local batAngleDeg = C.CrankOffsetDeg - -local catcherThrownBall = false - ----@alias Team { score: number, benchPosition: XyPair } ----@type table +---@alias Team { benchPosition: XyPair } +---@type table local teams = { home = { - score = 0, benchPosition = utils.xy(C.Screen.W + 10, C.Center.y), }, away = { - score = 0, benchPosition = utils.xy(-10, C.Center.y), }, } -local inning = 1 +---@alias TeamId 'home' | 'away' -local battingTeam = teams.away -local offenseState = C.Offense.batting - --- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0 -local secondsSinceLastRunnerMove = 0 -local secondsSincePitchAllowed = 0 +--- 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 TeamId | nil +---@field awayTeamSprites SpriteCollection +---@field homeTeamSprites SpriteCollection +---@class MutableState +---@field deltaSeconds number +---@field ball Ball +---@field battingTeam TeamId +---@field pitchIsOver boolean +---@field didSwing boolean +---@field offenseState OffenseState +---@field inning number +---@field stats Statistics +---@field batBase XyPair +---@field batTip XyPair +---@field batAngleDeg 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 = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper -local battingTeamSprites = AwayTeamSprites -local fieldingTeamSprites = HomeTeamSprites +---@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 panner Panner +---@field private state MutableState +Game = {} -local UserTeam = 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 = "away" ----@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ----@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } + local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat) + local awayTeamBlipper = blipper.new(100, settings.awayTeamSprites.smiling, settings.awayTeamSprites.lowHat) + local battingTeam = "away" + local runnerBlipper = battingTeam == "away" and awayTeamBlipper or homeTeamBlipper + local ball = Ball.new(gfx.animator) ----@type Pitch[] -local Pitches = { - -- Fastball - { - x = gfx.animator.new(0, C.PitchStartX, C.PitchStartX, playdate.easingFunctions.linear), - y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), - }, - -- Curve ball - { - x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX + 20, C.PitchStartX, utils.easingHill), - y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), - }, - -- Slider - { - x = gfx.animator.new(C.PitchFlyMs, C.PitchStartX - 20, C.PitchStartX, utils.easingHill), - y = gfx.animator.new(C.PitchFlyMs, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), - }, - -- Wobbbleball - { - x = { - currentValue = function() - return C.PitchStartX + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStartY) / 10)) - end, - reset = function() end, + local o = setmetatable({ + settings = settings, + announcer = announcer, + fielding = fielding, + homeTeamBlipper = homeTeamBlipper, + awayTeamBlipper = awayTeamBlipper, + panner = Panner.new(ball), + 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, + pitchIsOver = true, + didSwing = false, + battingTeamSprites = settings.awayTeamSprites, + fieldingTeamSprites = settings.homeTeamSprites, + runnerBlipper = runnerBlipper, + stats = Statistics.new(), }, - y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear), - }, -} + }, { __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() + o:returnToPitcher() + end) + + BootTune:play() + BootTune:setFinishCallback(function() + TinnyBackground:play() + end) + + return o +end + +---@param teamId TeamId +---@return TeamId +local function getOppositeTeamId(teamId) + if teamId == "home" then + return "away" + elseif teamId == "away" then + return "home" + else + error("Unknown TeamId: " .. (teamId or "nil")) + end +end + +function Game:getFieldingTeam() + return teams[getOppositeTeamId(self.state.battingTeam)] +end + +---@param side Side ---@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 - ret = side == C.Sides.offense + if self.settings.userTeam == self.state.battingTeam then + ret = side == "offense" else - ret = side == C.Sides.defense + ret = side == "defense" end return ret, not ret end ----@type LaunchBall -local function launchBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) - throwMeter:reset() - ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) -end - ---@param pitchFlyTimeMs number | nil ---@param pitchTypeIndex number | nil -local function pitch(pitchFlyTimeMs, pitchTypeIndex) - Fielding.markIneligible(fielding.fielders.pitcher) - ball.heldBy = nil - catcherThrownBall = false - offenseState = C.Offense.batting +---@param accuracy number The closer to 1.0, the better +function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy) + self.state.ball:markUncatchable() + self.state.ball.heldBy = nil + self.state.pitchIsOver = 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](accuracy, 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 + pitchTracker.secondsSinceLastPitch = 0 end -local function nextHalfInning() +function Game:pitcherIsOnTheMound() + return utils.distanceBetweenPoints(self.fielding.fielders.pitcher, C.PitcherStartPos) < C.BaseHitbox +end + +function Game:pitcherIsReady() + local pitcher = self.fielding.fielders.pitcher + return self:pitcherIsOnTheMound() + and ( + self.state.ball.heldBy == pitcher + or utils.distanceBetweenPoints(pitcher, self.state.ball) < C.BallCatchHitbox + or utils.distanceBetweenPoints(self.state.ball, C.PitchStart) < 2 + ) +end + +function Game:nextHalfInning() pitchTracker:reset() - local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home - local gameOver = inning == 9 and teams.away.score ~= teams.home.score - if not gameOver then - fielding:celebrate() - secondsSinceLastRunnerMove = -7 - fielding:benchTo(currentlyFieldingTeam.benchPosition) - announcer:say("SWITCHING SIDES...") - end + local homeScore, awayScore = utils.totalScores(self.state.stats) + local isFinalInning = self.state.inning >= self.settings.finalInning + local gameOver = isFinalInning and self.state.battingTeam == "home" and awayScore ~= homeScore + gameOver = gameOver or self.state.battingTeam == "away" and isFinalInning and homeScore > awayScore + Fielding.celebrate() if gameOver then - announcer:say("AND THAT'S THE BALL GAME!") - else - fielding:resetFielderPositions() - if battingTeam == teams.home then - inning = inning + 1 - end - battingTeam = currentlyFieldingTeam - playdate.timer.new(2000, function() - if battingTeam == teams.home then - battingTeamSprites = HomeTeamSprites - runnerBlipper = HomeTeamBlipper - fieldingTeamSprites = AwayTeamSprites - else - battingTeamSprites = AwayTeamSprites - fieldingTeamSprites = HomeTeamSprites - runnerBlipper = AwayTeamBlipper - end + self.announcer:say("THAT'S THE BALL GAME!") + playdate.timer.new(3000, function() + transitionTo(BoxScore.new(self.state.stats)) end) + return end + + 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.stats:pushInning() + 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 -local baserunning = Baserunning.new(announcer, nextHalfInning) -local npc = Npc.new(baserunning.runners, fielding.fielders) +---@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 -local function score(scoredRunCount) - battingTeam.score = battingTeam.score + scoredRunCount - announcer:say("SCORE!") +function Game:score(scoredRunCount) + -- TODO: end the game when it's the bottom of the ninth the home team is now in the lead. + -- outRunners/scoredRunners split + self:battingTeamCurrentInning().score = self:battingTeamCurrentInning().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] @@ -232,62 +311,73 @@ 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.baserunning.secondsSinceLastRunnerMove = 0 + self.state.offenseState = C.Offense.running return true end -local function nextBatter() - secondsSincePitchAllowed = -3 - baserunning.batter = nil +function Game:nextBatter() + pitchTracker.secondsSinceLastPitch = -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, "Strike out!") + self:nextBatter() end +local SwingBackDeg = 30 +local SwingForwardDeg = 170 + ---@param batDeg number -local function updateBatting(batDeg, batSpeed) +function Game:updateBatting(batDeg, batSpeed) + if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then + self.state.didSwing = true + end 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 self.state.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 + ) 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) @@ -297,52 +387,86 @@ 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) + local flyTimeMs = 2000 -- 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 - 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() + if utils.pointIsSquarelyAboveLine(utils.xy(ballDestX, ballDestY), C.OutfieldWall) then + playdate.timer.new(flyTimeMs, function() + -- Verify that the home run wasn't intercepted + if utils.within(1, self.state.ball.x, ballDestX) and utils.within(1, self.state.ball.y, ballDestY) then + self.announcer:say("HOME RUN!") + self.state.offenseState = C.Offense.homeRun + -- Linger on the home-run ball for a moment, before panning to the bases. + playdate.timer.new(1000, function() + self.panner:panTo(utils.xy(0, 0), function() + return self:pitcherIsReady() + end) + end) + end + end) + end - fielding:haveSomeoneChase(ballDestX, ballDestY) + local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) + self.state.ball:launch(ballDestX, ballDestY, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler) + + self.baserunning:convertBatterToRunner() + 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) +---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove +function Game:updateNonBatterRunners(appliedSpeed, forcedOnly) + local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove = + self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds) if runnersScored ~= 0 then - score(runnersScored) + self:score(runnersScored) end - return runnerMoved + return runnersStillMoving, secondsSinceLastRunnerMove 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) +function Game:returnToPitcher() + self.fielding:resetFielderPositions() + actionQueue:upsert("returnToPitcher", 60 * 1000, function() + while not self:pitcherIsOnTheMound() do + coroutine.yield() + end + if not self.baserunning.batter then + self.baserunning:pushNewBatter() + end + self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true) + end) +end + +---@param throwFly number +function Game:userPitch(throwFly, accuracy) + local aPressed = playdate.buttonIsPressed(playdate.kButtonA) + local bPressed = playdate.buttonIsPressed(playdate.kButtonB) + if not aPressed and not bPressed then + self:pitch(throwFly, 1, accuracy) + elseif aPressed and not bPressed then + self:pitch(throwFly, 2, accuracy) + elseif not aPressed and bPressed then + self:pitch(throwFly, 3, accuracy) + elseif aPressed and bPressed then + self:pitch(throwFly, 4, accuracy) 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) @@ -350,151 +474,183 @@ 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("offense") - if userOnDefense then - throwMeter:applyCharge(deltaSeconds, crankLimited) + local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds) + + if fielderHoldingBall then + 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 - if offenseState == C.Offense.batting then - if ball.y < C.StrikeZoneStartY then - pitchTracker.recordedPitchX = nil - elseif not pitchTracker.recordedPitchX then - pitchTracker.recordedPitchX = ball.x - end + if self.state.offenseState == C.Offense.batting then + pitchTracker:recordIfPassed(self.state.ball) - 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 + pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds end - if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then - local outcome = pitchTracker:updatePitchCounts() + if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then + local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning()) 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:returnToPitcher() + self.state.pitchIsOver = true + self.state.didSwing = false 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.pitchIsOver, 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, userOnOffense and crankLimited or 0, self.state.deltaSeconds) - if secondsSincePitchAllowed > C.PitchAfterSeconds then + if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then if userOnDefense then - local throwFly = throwMeter:readThrow() - if throwFly and not buttonControlledThrow(throwFly, true) then - userPitch(throwFly) + local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange) + if powerRatio then + local throwFly = C.PitchFlyMs / powerRatio + if throwFly and not self:buttonControlledThrow(throwFly, true) then + self:userPitch(throwFly, accuracy) + end 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 - else - secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds - if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then - -- End of play. Throw the ball back to the pitcher - launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) - fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly. - fielding:resetFielderPositions() - offenseState = C.Offense.batting - -- TODO: Remove, or replace with nextBatter() - if not baserunning.batter then - baserunning:pushNewBatter() + local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false) + if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then + -- End of play. Throw the ball back to the pitcher + self.state.offenseState = C.Offense.batting + self:returnToPitcher() + end + + if userOnDefense then + local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange) + if powerRatio then + local throwFly = C.PitchFlyMs / powerRatio + if throwFly then + self:buttonControlledThrow(throwFly) 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 + elseif self.state.offenseState == C.Offense.homeRun then + self:updateNonBatterRunners(C.WalkedRunnerSpeed * 2, false) + if #self.baserunning.runners == 0 then + -- Give the player a moment to enjoy their home run. + playdate.timer.new(1500, function() + self:returnToPitcher() + actionQueue:upsert("waitForPitcherToHaveBall", 10000, function() + while not self:pitcherIsReady() do + coroutine.yield() + end + self.state.offenseState = C.Offense.batting + end) + end) end end - local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds) - - if userOnDefense then - local throwFly = throwMeter:readThrow() - if throwFly then - buttonControlledThrow(throwFly) - end - end - if fielderHoldingBall then - local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) - if not userOnDefense and offenseState == C.Offense.running then - npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall) - 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 playdate.update() +function Game:update() playdate.timer.updateTimers() gfx.animation.blinker.updateAll() - updateGameState() + self:updateGameState() + local ball = self.state.ball gfx.clear() gfx.setColor(gfx.kColorBlack) - local offsetX, offsetY = getDrawOffset(ball.x, ball.y) + local offsetX, offsetY = self.panner:get(self.state.deltaSeconds) gfx.setDrawOffset(offsetX, offsetY) - GrassBackground:draw(-400, -240) + GrassBackground:draw(-400, -720) - local ballIsHeld = fielding:drawFielders(fieldingTeamSprites, ball) - - if offenseState == C.Offense.batting then - gfx.setLineWidth(5) - gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y) + ---@type { y: number, drawAction: fun() }[] + local characterDraws = {} + function addDraw(y, drawAction) + characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction } end - local playerHeightOffset = 10 - -- TODO? Scale sprites down as y increases - for _, runner in pairs(baserunning.runners) do - if runner == baserunning.batter then - if batAngleDeg > 50 and batAngleDeg < 200 then - battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset) - else - battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset) + local danceOffset = FielderDanceAnimator:currentValue() + ---@type Fielder | nil + local ballHeldBy + for _, fielder in pairs(self.fielding.fielders) do + addDraw(fielder.y + danceOffset, function() + local ballHeldByThisFielder = + drawFielder(self.state.fieldingTeamSprites, self.state.ball, fielder.x, fielder.y + danceOffset) + if ballHeldByThisFielder then + ballHeldBy = fielder end - else - -- TODO? Change blip speed depending on runner speed? - runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset) - end + end) end - for _, runner in pairs(baserunning.outRunners) do - battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset) + local playerHeightOffset = 20 + for _, runner in pairs(self.baserunning.runners) do + addDraw(runner.y, function() + 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 + self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset) + end + else + -- TODO? Change blip speed depending on runner speed? + self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset) + end + end) end - if not ballIsHeld then + table.sort(characterDraws, function(a, b) + return a.y < b.y + end) + for _, character in pairs(characterDraws) do + character.drawAction() + end + + if self.state.offenseState == C.Offense.batting then + gfx.setLineWidth(5) + gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y) + end + + for _, runner in pairs(self.baserunning.outRunners) do + self.state.battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset) + end + for _, runner in pairs(self.baserunning.scoredRunners) do + self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset) + end + + throwMeter:drawNearFielder(ballHeldBy) + + if not ballHeldBy then gfx.setLineWidth(2) gfx.setColor(gfx.kColorWhite) @@ -506,31 +662,30 @@ function playdate.update() 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) + local homeScore, awayScore = utils.totalScores(self.state.stats) + 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) - announcer:draw(C.Center.x, 10) + self.announcer:draw(C.Center.x, 10) if playdate.isCrankDocked() then + -- luacheck: ignore playdate.ui.crankIndicator:draw() end end -local function init() - playdate.display.setRefreshRate(50) - gfx.setBackgroundColor(gfx.kColorWhite) - playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) - fielding:resetFielderPositions(teams.home.benchPosition) - playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? +playdate.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? - playdate.timer.new(2000, function() - launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false) - end) - BootTune:play() - BootTune:setFinishCallback(function() - TinnyBackground:play() - end) -end - -init() +MainMenu.start(Game) diff --git a/src/music/MenuMusic.wav b/src/music/MenuMusic.wav new file mode 100644 index 0000000..e08f303 Binary files /dev/null and b/src/music/MenuMusic.wav differ diff --git a/src/npc.lua b/src/npc.lua index ca17096..921c4a4 100644 --- a/src/npc.lua +++ b/src/npc.lua @@ -18,16 +18,18 @@ function Npc.new(runners, fielders) }, { __index = Npc }) end +-- TODO: FAR more nuanced NPC batting. ---@param ball XyPair ----@param catcherThrownBall boolean +---@param pitchIsOver boolean ---@param deltaSec number ---@return number -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 +-- luacheck: no unused +function Npc:updateBatAngle(ball, pitchIsOver, deltaSec) + if not pitchIsOver and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed) else npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed - npcBatDeg = 200 + npcBatDeg = 230 end return npcBatDeg end @@ -119,35 +121,31 @@ 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) - return grabCandidate.catchEligible - end) + local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) nearestFielder.target = utils.xy(targetX, targetY) if nearestFielder == fielder then ball.heldBy = fielder else - launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true) - Fielding.markIneligible(nearestFielder) + ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true) end end 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 diff --git a/src/pitching.lua b/src/pitching.lua new file mode 100644 index 0000000..829035c --- /dev/null +++ b/src/pitching.lua @@ -0,0 +1,183 @@ +---@alias SimpleAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } +---@alias Pitch fun(ball: Ball): { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil } + +---@type pd_graphics_lib +local gfx = playdate.graphics + +local StrikeZoneWidth = C.StrikeZoneEndX - C.StrikeZoneStartX + +-- TODO? Also degrade speed +function getPitchMissBy(accuracy) + accuracy = accuracy or 1.0 + local missBy = (1 - accuracy) * StrikeZoneWidth * 3 + if math.random() > 0.5 then + missBy = missBy * -1 + end + return missBy +end + +---@type Pitch[] +Pitches = { + -- Fastball + function(accuracy) + return { + x = gfx.animator.new(0, C.PitchStart.x, getPitchMissBy(accuracy) + C.PitchStart.x, playdate.easingFunctions.linear), + y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), + } + end, + -- Curve ball + function(accuracy) + return { + x = gfx.animator.new(C.PitchFlyMs, getPitchMissBy(accuracy) + C.PitchStart.x + 20, C.PitchStart.x, utils.easingHill), + y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), + } + end, + -- Slider + function(accuracy) + return { + x = gfx.animator.new(C.PitchFlyMs, getPitchMissBy(accuracy) + C.PitchStart.x - 20, C.PitchStart.x, utils.easingHill), + y = gfx.animator.new(C.PitchFlyMs, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), + } + end, + -- Wobbbleball + function(accuracy, ball) + return { + x = { + currentValue = function() + return getPitchMissBy(accuracy) + C.PitchStart.x + (10 * math.sin((ball.yAnimator:currentValue() - C.PitchStart.y) / 10)) + end, + reset = function() end, + }, + y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStart.y, C.PitchEndY, playdate.easingFunctions.linear), + } + end, +} + +PitchOutcomes = { + StrikeOut = "StrikeOut", + Walk = "Walk", +} + +pitchTracker = { + --- Position of the pitch, or nil, if one has not been recorded. + ---@type number | nil + recordedPitchX = nil, + + -- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0 + secondsSinceLastPitch = 0, + + strikes = 0, + balls = 0, +} + +function pitchTracker:reset() + self.strikes = 0 + self.balls = 0 +end + +function pitchTracker:recordIfPassed(ball) + if ball.y < C.StrikeZoneStartY then + self.recordedPitchX = nil + elseif not self.recordedPitchX then + self.recordedPitchX = ball.x + end +end + +---@param didSwing boolean +---@param fieldingTeamInningData TeamInningData +function pitchTracker:updatePitchCounts(didSwing, fieldingTeamInningData) + if not self.recordedPitchX then + return + end + + local currentPitchingStats = fieldingTeamInningData.pitching + + if didSwing or self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then + self.strikes = self.strikes + 1 + currentPitchingStats.strikes = currentPitchingStats.strikes + 1 + if self.strikes >= 3 then + self:reset() + return PitchOutcomes.StrikeOut + end + else + self.balls = self.balls + 1 + currentPitchingStats.balls = currentPitchingStats.balls + 1 + if self.balls >= 4 then + self:reset() + return PitchOutcomes.Walk + end + end +end + +----------------- +-- Throw Meter -- +----------------- + +throwMeter = { + MinCharge = 25, + + value = 0, + idealPower = 50, + lastReadThrow = nil, + + --- Used at draw-time only. + ---@type Fielder | nil + lastThrower = nil, +} + +function throwMeter:reset() + self.value = 0 +end + +local crankQueue = {} + +--- Returns nil when a throw is NOT requested. +---@param chargeAmount number +---@return number | nil powerRatio, number | nil accuracy, boolean isPerfect +function throwMeter:readThrow(chargeAmount) + local ret = self:applyCharge(chargeAmount) + if ret then + local ratio = ret / self.idealPower + local accuracy + if ratio >= 1 then + accuracy = 1 / ratio / 2 + else + accuracy = 1 -- Accuracy is perfect on slow throws + end + return ratio * 1.5, accuracy, math.abs(ratio - 1) < 0.05 + end + return nil, nil, false +end + +--- If (within approx. a third of a second) the crank has moved more than 45 degrees, call that a throw. +---@param chargeAmount number +function throwMeter:applyCharge(chargeAmount) + if chargeAmount == 0 then + return + end + local currentTimeMs = playdate.getCurrentTimeMilliseconds() + local removedOne = false + while #crankQueue ~= 0 and (currentTimeMs - crankQueue[1].time) > 0.33 do + table.remove(crankQueue, 1) + removedOne = true -- At least 1/3 second has passed + end + crankQueue[#crankQueue + 1] = { time = currentTimeMs, chargeAmount = math.abs(chargeAmount) } + + if not removedOne then + return nil + end + + local currentCharge = 0 + for _, v in ipairs(crankQueue) do + currentCharge = currentCharge + v.chargeAmount + end + + if currentCharge > throwMeter.MinCharge then + self.lastReadThrow = currentCharge + print(tostring(currentCharge) .. " from " .. #crankQueue) + crankQueue = {} + return currentCharge + else + return nil + end +end diff --git a/src/test/testBaserunning.lua b/src/test/testBaserunning.lua index a27a707..d94fc02 100644 --- a/src/test/testBaserunning.lua +++ b/src/test/testBaserunning.lua @@ -11,7 +11,7 @@ function buildBaserunning() return baserunning, thirdOutCallbackData end ----@alias BaseIndexOrXyPair (integer | XyPair) +---@alias BaseIndexOrXyPair (number | XyPair) --- NOTE: in addition to the given runners, there is implicitly a batter running from first. ---@param runnerLocations BaseIndexOrXyPair[] @@ -44,7 +44,7 @@ end ---@param expected boolean ---@param fielderWithBallAt XyPair ----@param when integer[][] +---@param when number[][] function assertRunnerOutCondition(expected, when, fielderWithBallAt) local msg = expected and "out" or "safe" for _, runnersOn in ipairs(when) do diff --git a/src/utils.lua b/src/utils.lua index 8357bb6..5bc364b 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -1,4 +1,4 @@ --- selene: allow(unscoped_variables) +-- luacheck no new globals utils = {} --- @alias XyPair { @@ -70,17 +70,28 @@ end ---@param mover { x: number, y: number } ---@param speed number ---@param target { x: number, y: number } ----@return boolean -function utils.moveAtSpeed(mover, speed, target) +---@param tau number | nil +---@return boolean isStillMoving +function utils.moveAtSpeed(mover, speed, target, tau) local x, y, distance = utils.normalizeVector(mover.x, mover.y, target.x, target.y) - if distance > 1 then - mover.x = mover.x - (x * speed) - mover.y = mover.y - (y * speed) - return true + if distance == 0 then + return false end - return false + if distance > (tau or 1) then + mover.x = mover.x - (x * speed) + mover.y = mover.y - (y * speed) + else + mover.x = target.x + mover.y = target.y + end + + return true +end + +function utils.within(within, n1, n2) + return math.abs(n1 - n2) < within end ---@generic T @@ -164,6 +175,22 @@ function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, li return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2) end +--- Returns true if the given point is anywhere above the given line, with no upper bound. +--- This, if used for home run calculations, would not take into account balls that curve around the foul poles. +---@param point XyPair +---@param linePoints XyPair[] +---@return boolean +function utils.pointIsSquarelyAboveLine(point, linePoints) + for i = 2, #linePoints do + local prev = linePoints[i - 1] + local next = linePoints[i] + if point.x >= prev.x and point.x <= next.x then + return not utils.pointUnderLine(point.x, point.y, prev.x, prev.y, next.x, next.y) + end + end + return false +end + --- Returns true only if the point is below the given line. ---@return boolean function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2) @@ -216,76 +243,19 @@ function utils.getNearestOf(array, x, y, extraCondition) return nearest, nearestDistance end --- selene: allow(unscoped_variables) -PitchOutcomes = { - StrikeOut = {}, - Walk = {}, -} - --- selene: allow(unscoped_variables) -pitchTracker = { - --- Position of the pitch, or nil, if one has not been recorded. - ---@type number | nil - recordedPitchX = nil, - - strikes = 0, - balls = 0, - - reset = function(self) - self.strikes = 0 - self.balls = 0 - end, - - updatePitchCounts = function(self) - if not self.recordedPitchX then - return - end - - if self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then - self.strikes = self.strikes + 1 - if self.strikes >= 3 then - self:reset() - return PitchOutcomes.StrikeOut - end - else - self.balls = self.balls + 1 - if self.balls >= 4 then - self:reset() - return PitchOutcomes.Walk - end - end - end, -} - ------------------ --- Throw Meter -- ------------------ - --- selene: allow(unscoped_variables) -throwMeter = { - value = 0, -} - -function throwMeter:reset() - self.value = 0 -end - ----@return number | nil flyTimeMs Returns nil when a throw is NOT requested. -function throwMeter:readThrow() - if self.value > C.ThrowMeterMax then - return (C.PitchFlyMs / (self.value / C.ThrowMeterMax)) +---@param stats Statistics +---@return number homeScore, number awayScore +function utils.totalScores(stats) + local homeScore = 0 + local awayScore = 0 + for _, inning in pairs(stats.innings) do + homeScore = homeScore + inning.home.score + awayScore = awayScore + inning.away.score end - return nil -end ---- Applies the given charge, but drains some meter for how much time has passed ----@param delta number ----@param chargeAmount number -function throwMeter:applyCharge(delta, chargeAmount) - self.value = math.max(0, self.value - (delta * C.ThrowMeterDrainPerSec)) - self.value = self.value + math.abs(chargeAmount * C.UserThrowPower) + return homeScore, awayScore end if not playdate then - return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes, throwMeter = throwMeter } + return utils end