diff --git a/Makefile b/Makefile index 74567df..d059610 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -SOURCE_FILES := src/utils.lua src/constants.lua src/assets.lua src/draw/* src/dbg.lua src/npc.lua src/announcer.lua src/graphics.lua src/main.lua +SOURCE_FILES := $(shell grep "import '" src/main.lua | grep -v CoreLibs | sed "s/.*'\(.*\)'.*/\1/") main.lua GENERATED_FILES := src/assets.lua all: diff --git a/src/constants.lua b/src/constants.lua index 12255dd..3d1af86 100644 --- a/src/constants.lua +++ b/src/constants.lua @@ -98,3 +98,5 @@ C.CrankPower = 10 --- How fast baserunners move after a walk C.WalkedRunnerSpeed = 10 + +C.ResetFieldersAfterSeconds = 3 diff --git a/src/dbg.lua b/src/dbg.lua index 1cba6fc..37b754e 100644 --- a/src/dbg.lua +++ b/src/dbg.lua @@ -21,9 +21,9 @@ 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(runners) - newRunner() - newRunner() - newRunner() + utils.newRunner() + utils.newRunner() + utils.newRunner() runners[2].x = C.Bases[C.First].x runners[2].y = C.Bases[C.First].y runners[2].nextBase = C.Bases[C.Second] diff --git a/src/field.lua b/src/field.lua new file mode 100644 index 0000000..0dae341 --- /dev/null +++ b/src/field.lua @@ -0,0 +1,56 @@ +---@param name string +---@param speed number +---@return Fielder +function newFielder(name, speed) + return { + name = name, + speed = speed, + } +end + +-- selene: allow(unscoped_variables) +Field = { + fielders = { + first = newFielder("First", 40), + second = newFielder("Second", 40), + shortstop = newFielder("Shortstop", 40), + third = newFielder("Third", 40), + pitcher = newFielder("Pitcher", 30), + catcher = newFielder("Catcher", 35), + left = newFielder("Left", 40), + center = newFielder("C.Center", 40), + right = newFielder("Right", 40), + }, +} + +--- Actually only benches the infield, because outfielders are far away! +---@param position XYPair +function Field.benchTo(self, position) + self.fielders.first.target = position + self.fielders.second.target = position + self.fielders.shortstop.target = position + self.fielders.third.target = position + self.fielders.pitcher.target = position + self.fielders.catcher.target = position +end + +--- Resets the target positions of all fielders to their defaults (at their field positions). +---@param fromOffTheField XYPair | nil If provided, also sets all runners' current position to one centralized location. +function Field.resetFielderPositions(self, fromOffTheField) + if fromOffTheField then + for _, fielder in pairs(self.fielders) do + fielder.x = fromOffTheField.x + fielder.y = fromOffTheField.y + end + end + + self.fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) + self.fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) + self.fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) + self.fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) + self.fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y) + self.fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) + self.fielders.left.target = utils.xy(C.Screen.W * -1, C.Screen.H * -0.2) + self.fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4) + self.fielders.right.target = utils.xy(C.Screen.W * 2, self.fielders.left.target.y) +end diff --git a/src/graphics.lua b/src/graphics.lua index 721488b..0115c23 100644 --- a/src/graphics.lua +++ b/src/graphics.lua @@ -2,19 +2,19 @@ --- XXX --- XOX --- Where each character is the size of the screen, and 'O' is the default view. -function getDrawOffset(screenW, screenH, ballX, ballY) +function getDrawOffset(ballX, ballY) local offsetX, offsetY - if ballY > screenH then + if ballY > C.Screen.H or ballX >= C.BallOffscreen then return 0, 0 end offsetY = math.max(0, -1 * ballY) - if ballX > 0 and ballX < screenW then + if ballX > 0 and ballX < C.Screen.W then offsetX = 0 elseif ballX < 0 then - offsetX = math.max(-1 * screenW, ballX * -1) - elseif ballX > screenW then - offsetX = math.min(screenW * 2, (ballX * -1) + screenW) + offsetX = math.max(-1 * C.Screen.W, ballX * -1) + elseif ballX > C.Screen.W then + offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W) end return offsetX * 1.3, offsetY * 1.5 diff --git a/src/main.lua b/src/main.lua index 17cb06c..31ea922 100644 --- a/src/main.lua +++ b/src/main.lua @@ -30,10 +30,11 @@ import 'assets.lua' import 'announcer.lua' import 'dbg.lua' +import 'field.lua' import 'graphics.lua' import 'npc.lua' -import 'draw/overlay' -import 'draw/fielder' +import 'draw/overlay.lua' +import 'draw/fielder.lua' -- stylua: ignore end -- selene: allow(shadowing) @@ -44,16 +45,72 @@ local PlayerImageBlipper = blipper.new(100, Player, PlayerLowHat) local FielderDanceAnimator = gfx.animator.new(1, 10, 0, utils.easingHill) FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1 -function fieldersDance() - FielderDanceAnimator:reset(C.DanceBounceMs) -end - -local ballAnimatorY = gfx.animator.new(0, C.BallOffscreen, C.BallOffscreen, playdate.easingFunctions.linear) -local ballAnimatorX = gfx.animator.new(0, C.BallOffscreen, C.BallOffscreen, playdate.easingFunctions.linear) - ---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) } ---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil } +local ballAnimatorX = utils.staticAnimator(C.BallOffscreen) +local ballAnimatorY = utils.staticAnimator(C.BallOffscreen) +local ballSizeAnimator = utils.staticAnimator(C.SmallestBallRadius) + +-- TODO? Replace this AND ballSizeAnimator with a ballAnimatorZ? +-- ...that might lose some of the magic of both. Compromise available? idk +local ballFloatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill) + +local deltaSeconds = 0 + +local BatterHandPos = utils.xy(10, 25) + +local batBase = utils.xy(C.Center.x - 34, 215) +local batTip = utils.xy(0, 0) + +local ball = { + x = C.Center.x --[[@as number]], + y = C.Center.y --[[@as number]], + z = 0, + size = C.SmallestBallRadius, + heldBy = nil --[[@type Runner | nil]], +} + +---@alias Team { score: number, 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 PlayerTeam = teams.away +local battingTeam = teams.away +local outs = 0 +local inning = 1 +local offenseMode = C.Offense.batting + +--- @type Runner[] +local runners = {} + +--- @type Runner[] +local outRunners = {} + +---@type Runner | nil +local batter = utils.newRunner(runners) + +local throwMeter = 0 + +-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0 +local secondsSinceLastRunnerMove = 0 + +-- TODO: Replace with a timer, repeatedly reset instead of setting to 0 +local secondsSincePitchAllowed = -5 +local catcherThrownBall = false + +local batAngleDeg = C.CrankOffsetDeg + ---@type Pitch[] local Pitches = { -- Fastball @@ -83,39 +140,6 @@ local Pitches = { }, } -local BatterHandPos = utils.xy(10, 25) - -local batBase = utils.xy(C.Center.x - 34, 215) -local batTip = utils.xy(0, 0) - -local ball = { - x = C.Center.x --[[@as number]], - y = C.Center.y --[[@as number]], - z = 0, - size = C.SmallestBallRadius, - heldBy = nil --[[@type Runner | nil]], -} - ----@alias Team { score: number, 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 PlayerTeam = teams.home -local battingTeam = teams.away -local outs = 0 -local inning = 1 -local offenseMode = C.Offense.batting - ---@return boolean playerIsOnSide, boolean playerIsOnOtherSide function playerIsOn(side) local ret @@ -127,93 +151,6 @@ function playerIsOn(side) return ret, not ret end --- TODO? Replace this AND ballSizeAnimator with a ballHeightAnimator --- ...that might lose some of the magic of both. Compromise available? idk -local ballFloatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill) -local BallSizeMs = 2000 -local ballSizeAnimator = gfx.animator.new(BallSizeMs, 9, C.SmallestBallRadius, utils.easingHill) - -local deltaSeconds = 0 - ----@param name string ----@param speed number ----@return Fielder -function newFielder(name, speed) - return { - name = name, - speed = speed, - } -end - ----@type table -local fielders = { - first = newFielder("First", 40), - second = newFielder("Second", 40), - shortstop = newFielder("Shortstop", 40), - third = newFielder("Third", 40), - pitcher = newFielder("Pitcher", 30), - catcher = newFielder("Catcher", 35), - left = newFielder("Left", 40), - center = newFielder("C.Center", 40), - right = newFielder("Right", 40), -} - ---- Actually only benches the infield, because outfielders are far away! ----@param position XYPair -function benchAllFielders(position) - fielders.first.target = position - fielders.second.target = position - fielders.shortstop.target = position - fielders.third.target = position - fielders.pitcher.target = position - fielders.catcher.target = position -end - ---- Resets the target positions of all fielders to their defaults (at their field positions). ----@param fromOffTheField XYPair | nil If provided, also sets all runners' current position to one centralized location. -function resetFielderPositions(fromOffTheField) - if fromOffTheField then - for _, fielder in pairs(fielders) do - fielder.x = fromOffTheField.x - fielder.y = fromOffTheField.y - end - end - - fielders.first.target = utils.xy(C.Screen.W - 65, C.Screen.H * 0.48) - fielders.second.target = utils.xy(C.Screen.W * 0.70, C.Screen.H * 0.30) - fielders.shortstop.target = utils.xy(C.Screen.W * 0.30, C.Screen.H * 0.30) - fielders.third.target = utils.xy(C.Screen.W * 0.1, C.Screen.H * 0.48) - fielders.pitcher.target = utils.xy(C.PitcherStartPos.x, C.PitcherStartPos.y) - fielders.catcher.target = utils.xy(C.Screen.W * 0.475, C.Screen.H * 0.92) - fielders.left.target = utils.xy(C.Screen.W * -1, C.Screen.H * -0.2) - fielders.center.target = utils.xy(C.Center.x, C.Screen.H * -0.4) - fielders.right.target = utils.xy(C.Screen.W * 2, fielders.left.target.y) -end - ---- @type Runner[] -local runners = {} - ---- @type Runner[] -local outRunners = {} - ----@return Runner -function newRunner() - local new = { - x = C.RightHandedBattersBox.x - 60, - y = C.RightHandedBattersBox.y + 60, - nextBase = C.RightHandedBattersBox, - prevBase = nil, - forcedTo = C.Bases[C.First], - } - runners[#runners + 1] = new - return new -end - ----@type Runner | nil -local batter = newRunner() - -local throwMeter = 0 - --- "Throws" the ball from its current position to the given destination. ---@param destX number ---@param destY number @@ -242,10 +179,6 @@ function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler end end --- TODO: Replace with a timer, repeatedly reset instead of setting to 0 -local secondsSincePitchAllowed = -5 -local catcherThrownBall = false - ---@param pitchFlyTimeMs number | nil ---@param pitchTypeIndex number | nil function pitch(pitchFlyTimeMs, pitchTypeIndex) @@ -316,10 +249,6 @@ function updateForcedRunners() end end -local ResetFieldersAfterSeconds = 5 --- TODO: Replace with a timer, repeatedly reset, instead of setting to 0 -local secondsSinceLastRunnerMove = 0 - ---@param runner integer | Runner function outRunner(runner, message) if type(runner) ~= "number" then @@ -346,9 +275,9 @@ function outRunner(runner, message) 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 - fieldersDance() + FielderDanceAnimator:reset(C.DanceBounceMs) secondsSinceLastRunnerMove = -7 - benchAllFielders(currentlyFieldingTeam.benchPosition) + Field:benchTo(currentlyFieldingTeam.benchPosition) announcer:say("SWITCHING SIDES...") end while #runners > 0 do @@ -361,7 +290,7 @@ function outRunner(runner, message) if gameOver then announcer:say("AND THAT'S THE BALL GAME!") else - resetFielderPositions() + Field:resetFielderPositions() if battingTeam == teams.home then inning = inning + 1 end @@ -427,7 +356,7 @@ end function tryToMakeAnOut(fielder) local targetX, targetY = getNextOutTarget() if targetX ~= nil and targetY ~= nil then - local nearestFielder = utils.getNearestOf(fielders, targetX, targetY) + local nearestFielder = utils.getNearestOf(Field.fielders, targetX, targetY) nearestFielder.target = utils.xy(targetX, targetY) if nearestFielder == fielder then ball.heldBy = fielder @@ -461,7 +390,7 @@ function buttonControlledThrow(thrower, throwFlyMs, forbidThrowHome) return false end - local closestFielder = utils.getNearestOf(fielders, targetBase.x, targetBase.y, function(fielder) + local closestFielder = utils.getNearestOf(Field.fielders, targetBase.x, targetBase.y, function(fielder) return fielder ~= thrower end) @@ -524,7 +453,7 @@ function updateFielder(fielder) if playerIsOn(C.Sides.defense) then local throwFly = readThrow() if throwFly then - buttonControlledThrow(fielders.pitcher, throwFly) + buttonControlledThrow(Field.fielders.pitcher, throwFly) end else updateNpcFielder(fielder, outedSomeRunner) @@ -586,22 +515,19 @@ function updateRunner(runner, runnerIndex, appliedSpeed) return prevX ~= runner.x or prevY ~= runner.y end ----@type number -local batAngleDeg - function nextBatter() batter = nil playdate.timer.new(2000, function() pitchTracker:reset() if not batter then - batter = newRunner() + batter = utils.newRunner(runners) end end) end function walk() announcer:say("Walk!") - fielders.first.target = C.Bases[C.First] + Field.fielders.first.target = C.Bases[C.First] batter.nextBase = C.Bases[C.First] batter.prevBase = C.Bases[C.Home] offenseMode = C.Offense.walking @@ -653,14 +579,14 @@ function updateBatting(batDeg, batSpeed) local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill) throwBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler) - fielders.first.target = C.Bases[C.First] + Field.fielders.first.target = C.Bases[C.First] batter.nextBase = C.Bases[C.First] batter.prevBase = C.Bases[C.Home] updateForcedRunners() batter.forcedTo = C.Bases[C.First] batter = nil -- Demote batter to a mere runner - local chasingFielder = utils.getNearestOf(fielders, ballDestX, ballDestY) + local chasingFielder = utils.getNearestOf(Field.fielders, ballDestX, ballDestY) chasingFielder.target = { x = ballDestX, y = ballDestY } end end @@ -721,10 +647,12 @@ function updateGameState() if ball.heldBy then ball.x = ball.heldBy.x ball.y = ball.heldBy.y + ball.size = C.SmallestBallRadius else ball.x = ballAnimatorX:currentValue() ball.z = ballFloatAnimator:currentValue() ball.y = ballAnimatorY:currentValue() + ball.z + ball.size = ballSizeAnimator:currentValue() end local playerOnOffense, playerOnDefense = playerIsOn(C.Sides.offense) @@ -741,7 +669,7 @@ function updateGameState() pitchTracker.recordedPitchX = ball.x end - local pitcher = fielders.pitcher + local pitcher = Field.fielders.pitcher if utils.distanceBetween(pitcher.x, pitcher.y, C.PitchStartX, C.PitchStartY) < C.BaseHitbox then secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds end @@ -787,12 +715,12 @@ function updateGameState() secondsSinceLastRunnerMove = 0 else secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds - if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then + if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then throwBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) - resetFielderPositions() + Field:resetFielderPositions() offenseMode = C.Offense.batting if not batter then - batter = newRunner() + batter = utils.newRunner(runners) end end end @@ -803,7 +731,7 @@ function updateGameState() end end - for _, fielder in pairs(fielders) do + for _, fielder in pairs(Field.fielders) do updateFielder(fielder) end walkAwayOutRunners() @@ -817,17 +745,14 @@ function playdate.update() gfx.clear() gfx.setColor(gfx.kColorBlack) - local offsetX, offsetY = 0, 0 - if ball.x < C.BallOffscreen then - offsetX, offsetY = getDrawOffset(C.Screen.W, C.Screen.H, ball.x, ball.y) - gfx.setDrawOffset(offsetX, offsetY) - end + local offsetX, offsetY = getDrawOffset(ball.x, ball.y) + gfx.setDrawOffset(offsetX, offsetY) GrassBackground:draw(-400, -240) local fielderDanceHeight = FielderDanceAnimator:currentValue() local ballIsHeld = false - for _, fielder in pairs(fielders) do + for _, fielder in pairs(Field.fielders) do ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld end @@ -853,6 +778,7 @@ function playdate.update() PlayerImageBlipper:draw(false, runner.x, runner.y) end end + for _, runner in pairs(outRunners) do PlayerFrown:draw(runner.x, runner.y) end @@ -880,7 +806,7 @@ function init() playdate.display.setRefreshRate(50) gfx.setBackgroundColor(gfx.kColorWhite) playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) - resetFielderPositions(teams.home.benchPosition) + Field:resetFielderPositions(teams.home.benchPosition) playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? playdate.timer.new(2000, function() diff --git a/src/utils.lua b/src/utils.lua index a0c2531..0a3304e 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -1,6 +1,13 @@ -- selene: allow(unscoped_variables) utils = {} +--- @alias XYPair { +--- x: number, +--- y: number, +--- } + +local sqrt = math.sqrt + function utils.easingHill(t, b, c, d) c = c + 0.0 -- convert to float to prevent integer overflow t = t / d @@ -9,10 +16,18 @@ function utils.easingHill(t, b, c, d) return (c * t) + b end ---- @alias XYPair { ---- x: number, ---- y: number, ---- } +--- Build an "animator" whose `:currentValue()` always returns the given value. +--- Essentially an "empty object" pattern for initial object positions. +---@param value number +---@return PseudoAnimator +function utils.staticAnimator(value) + return { + currentValue = function(_) + return value + end, + reset = function(_) end + } +end ---@param x number ---@param y number @@ -84,7 +99,7 @@ end function utils.distanceBetween(x1, y1, x2, y2) local x = x1 - x2 local y = y1 - y2 - return math.sqrt((x * x) + (y * y)), x, y + return sqrt((x * x) + (y * y)), x, y end ---@return number distance, number x, number y, number z @@ -92,7 +107,20 @@ function utils.distanceBetweenZ(x1, y1, z1, x2, y2, z2) local x = x1 - x2 local y = y1 - y2 local z = z1 - z2 - return math.sqrt((x * x) + (y * y) + (z * z)), x, y, z + return sqrt((x * x) + (y * y) + (z * z)), x, y, z +end + +---@return Runner +function utils.newRunner(runners) + local new = { + x = C.RightHandedBattersBox.x - 60, + y = C.RightHandedBattersBox.y + 60, + nextBase = C.RightHandedBattersBox, + prevBase = nil, + forcedTo = C.Bases[C.First], + } + runners[#runners + 1] = new + return new end --- Returns true only if the point is below the given line, within the x bounds of said line, and above the bottomBound