From f67d6262ac41fcc086b7506b36fc5a4cab731716 Mon Sep 17 00:00:00 2001 From: Sage Vaillancourt Date: Mon, 10 Feb 2025 21:22:21 -0500 Subject: [PATCH] Fix some NPC positioning. Basic foul-ball implementation. Some balance tweaks. Generally faster play. Move pitchMeter to utils. Player -> User when talking about the human player instead of a baseball player. Slightly delay scoreboard changes. Animate ball-strike entry and exit. --- src/ball.lua | 2 +- src/baserunning.lua | 2 +- src/constants.lua | 33 ++++++++++++++---- src/draw/overlay.lua | 69 +++++++++++++++++++++++++++++++++----- src/fielding.lua | 8 ++--- src/main.lua | 80 +++++++++++++++++++++++--------------------- src/npc.lua | 5 +++ src/utils.lua | 65 ++++++++++++++++++++++++++++++++--- 8 files changed, 199 insertions(+), 65 deletions(-) diff --git a/src/ball.lua b/src/ball.lua index fa24d5b..3fef096 100644 --- a/src/ball.lua +++ b/src/ball.lua @@ -44,7 +44,7 @@ function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScal self.heldBy = nil if not flyTimeMs then - flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * 5 + flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower end if customBallScaler then diff --git a/src/baserunning.lua b/src/baserunning.lua index c3812e1..98b0286 100644 --- a/src/baserunning.lua +++ b/src/baserunning.lua @@ -209,7 +209,7 @@ end --- Returns true only if at least one of the given runners moved during this update ---@param appliedSpeed number ---@return boolean someRunnerMoved, number runnersScored -function Baserunning:updateRunning(appliedSpeed, forcedOnly, deltaSeconds) +function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) local someRunnerMoved = false local runnersScored = 0 diff --git a/src/constants.lua b/src/constants.lua index fbf5687..1687e13 100644 --- a/src/constants.lua +++ b/src/constants.lua @@ -30,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 - 35, + x = C.Bases[C.Home].x - 30, y = C.Bases[C.Home].y, } @@ -42,21 +42,40 @@ C.NextBaseMap = { [C.Bases[C.Third]] = C.Bases[C.Home], } +C.LeftFoulLine = { + x1 = C.Center.x, + y1 = 220, + x2 = -1800, + y2 = -465, +} + +C.RightFoulLine = { + x1 = C.Center.x, + y1 = 218, + x2 = 2120, + y2 = -465, +} + --- Angle to align the bat to C.CrankOffsetDeg = 90 C.DanceBounceMs = 500 C.DanceBounceCount = 4 +C.ScoreboardDelayMs = 2000 + --- Used to draw the ball well out of bounds, and --- generally as a check for whether or not it's in play. C.BallOffscreen = 999 -C.PitchAfterSeconds = 7 +C.PitchAfterSeconds = 6 +C.ReturnToPitcherAfterSeconds = 2.4 C.PitchFlyMs = 1050 -C.PitchStartX = 195 +C.PitchStartX = 190 C.PitchStartY, C.PitchEndY = 105, 240 +C.DefaultLaunchPower = 4 + --- The max distance at which a fielder can tag out a runner. C.TagDistance = 15 @@ -69,7 +88,7 @@ C.BattingPower = 20 C.SmallestBallRadius = 6 -C.BatLength = 50 +C.BatLength = 35 -- TODO: enums implemented this way are probably going to be difficult to serialize! @@ -95,16 +114,16 @@ C.PitcherStartPos = { y = C.Screen.H * 0.40, } -C.ThrowMeterMax = 15 +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 -C.PitchPower = 0.3 +C.UserThrowPower = 0.3 --- How fast baserunners move after a walk C.WalkedRunnerSpeed = 10 -C.ResetFieldersAfterSeconds = 7 +C.ResetFieldersAfterSeconds = 2.5 diff --git a/src/draw/overlay.lua b/src/draw/overlay.lua index df970ac..a1288db 100644 --- a/src/draw/overlay.lua +++ b/src/draw/overlay.lua @@ -38,11 +38,32 @@ local BallStrikeMarginY = 4 local BallStrikeWidth = 60 local BallStrikeHeight = (BallStrikeMarginY * 2) + ScoreFont:getHeight() +local BallStrikeAnimatorIn = + playdate.graphics.animator.new(500, BallStrikeHeight, 0, playdate.easingFunctions.outBounce) + +local BallStrikeAnimatorOut = + playdate.graphics.animator.new(500, 0, BallStrikeHeight, playdate.easingFunctions.linear) + +-- Start out of frame. +local currentBallStrikeAnimator = utils.staticAnimator(20) + function drawBallsAndStrikes(x, y, balls, strikes) if balls == 0 and strikes == 0 then - return + if currentBallStrikeAnimator == BallStrikeAnimatorIn then + currentBallStrikeAnimator = BallStrikeAnimatorOut + currentBallStrikeAnimator:reset() + end + if currentBallStrikeAnimator:ended() then + return + end + end + if balls + strikes == 1 and currentBallStrikeAnimator ~= BallStrikeAnimatorIn then + -- First pitch - should pop in now. + currentBallStrikeAnimator = BallStrikeAnimatorIn + currentBallStrikeAnimator:reset() end + y = y + currentBallStrikeAnimator:currentValue() gfx.setColor(gfx.kColorBlack) gfx.fillRect(x, y, BallStrikeWidth, BallStrikeHeight) local originalDrawMode = gfx.getImageDrawMode() @@ -72,11 +93,19 @@ function getIndicators(teams, battingTeam) return "", IndicatorWidth, Indicator, 0 end -function drawScoreboard(x, y, teams, outs, battingTeam, inning) - local homeScore = teams.home.score - local awayScore = teams.away.score +local stats = { + homeScore = 0, + awayScore = 0, + outs = 0, + inning = 1, + battingTeam = nil, +} - local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, battingTeam) +function drawScoreboardImpl(x, y, teams) + local homeScore = stats.homeScore + local awayScore = stats.awayScore + + local homeIndicator, homeOffset, awayIndicator, awayOffset = getIndicators(teams, stats.battingTeam) local homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore) local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore) @@ -96,7 +125,7 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning) ScoreFont:drawText(homeScoreText, x + ScoreboardMarginX + homeOffset, y + 6) ScoreFont:drawText(awayScoreText, x + ScoreboardMarginX + awayOffset, y + 22) local inningOffsetX = (x + ScoreboardMarginX + IndicatorWidth) + (4 * 2.5 * OutBubbleRadius) - ScoreFont:drawText(inning, inningOffsetX, y + 39) + ScoreFont:drawText(tostring(stats.inning), inningOffsetX, y + 39) gfx.setImageDrawMode(originalDrawMode) @@ -107,10 +136,34 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning) return (x + ScoreboardMarginX + OutBubbleRadius + IndicatorWidth) + circleOffset, y + 46, OutBubbleRadius end - for i = outs, 2 do + for i = stats.outs, 2 do gfx.drawCircleAtPoint(circleParams(i)) end - for i = 0, (outs - 1) do + for i = 0, (stats.outs - 1) do gfx.fillCircleAtPoint(circleParams(i)) end end + +local newStats = stats + +function drawScoreboard(x, y, teams, outs, battingTeam, inning) + if + newStats.homeScore ~= teams.home.score + or newStats.awayScore ~= teams.away.score + or newStats.outs ~= outs + or newStats.inning ~= inning + or newStats.battingTeam ~= battingTeam + then + newStats = { + homeScore = teams.home.score, + awayScore = teams.away.score, + outs = outs, + inning = inning, + battingTeam = battingTeam, + } + playdate.timer.new(C.ScoreboardDelayMs, function() + stats = newStats + end) + end + drawScoreboardImpl(x, y, teams) +end diff --git a/src/fielding.lua b/src/fielding.lua index 5362618..3c88bea 100644 --- a/src/fielding.lua +++ b/src/fielding.lua @@ -124,7 +124,7 @@ end ---@param launchBall LaunchBall ---@param throwFlyMs number ---@return ActionResult -local function playerThrowToImpl(field, targetBase, launchBall, throwFlyMs) +local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs) if field.fielderTouchingBall == nil then return ActionResult.NeedsMoreTime end @@ -142,9 +142,9 @@ end ---@param targetBase Base ---@param launchBall LaunchBall ---@param throwFlyMs number -function Fielding:playerThrowTo(targetBase, launchBall, throwFlyMs) +function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs) local maxTryTimeMs = 5000 - actionQueue:upsert("playerThrowTo", maxTryTimeMs, function() - return playerThrowToImpl(self, targetBase, launchBall, throwFlyMs) + actionQueue:upsert("userThrowTo", maxTryTimeMs, function() + return userThrowToImpl(self, targetBase, launchBall, throwFlyMs) end) end diff --git a/src/main.lua b/src/main.lua index 2f8350f..01664ea 100644 --- a/src/main.lua +++ b/src/main.lua @@ -62,20 +62,18 @@ local teams = { }, } -local PlayerTeam = teams.away +local UserTeam = teams.away local battingTeam = teams.away local inning = 1 local offenseState = C.Offense.batting -local throwMeter = 0 - -- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0 local secondsSinceLastRunnerMove = 0 local secondsSincePitchAllowed = -5 local catcherThrownBall = false -local BatterHandPos = utils.xy(10, 15) +local BatterHandPos = utils.xy(25, 15) local batBase = utils.xy(C.Center.x - 34, 215) local batTip = utils.xy(0, 0) @@ -118,10 +116,10 @@ local Pitches = { }, } ----@return boolean playerIsOnSide, boolean playerIsOnOtherSide -local function playerIsOn(side) +---@return boolean userIsOnSide, boolean playerIsOnOtherSide +local function userIsOn(side) local ret - if PlayerTeam == battingTeam then + if UserTeam == battingTeam then ret = side == C.Sides.offense else ret = side == C.Sides.defense @@ -137,7 +135,7 @@ end ---@param floaty boolean | nil ---@param customBallScaler pd_animator | nil local function launchBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) - throwMeter = 0 + throwMeter:reset() ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler) end @@ -203,13 +201,6 @@ local function score(scoredRunCount) announcer:say("SCORE!") end -local function readThrow() - if throwMeter > C.ThrowMeterMax then - return (C.PitchFlyMs / (throwMeter / C.ThrowMeterMax)) - end - return nil -end - ---@param throwFlyMs number ---@return boolean didThrow local function buttonControlledThrow(throwFlyMs, forbidThrowHome) @@ -227,9 +218,9 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome) end -- Power for this throw has already been determined - throwMeter = 0 + throwMeter:reset() - fielding:playerThrowTo(targetBase, launchBall, throwFlyMs) + fielding:userThrowTo(targetBase, launchBall, throwFlyMs) secondsSinceLastRunnerMove = 0 offenseState = C.Offense.running @@ -237,6 +228,7 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome) end local function nextBatter() + secondsSincePitchAllowed = -3 baserunning.batter = nil playdate.timer.new(2000, function() pitchTracker:reset() @@ -277,6 +269,7 @@ local function updateBatting(batDeg, batSpeed) and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H) and ball.y < 232 then + -- Hit! BatCrackReverb:play() offenseState = C.Offense.running local ballAngle = batAngle + math.rad(90) @@ -290,10 +283,17 @@ local function updateBatting(batDeg, batSpeed) end local ballDestX = ball.x + (ballVelX * C.BattingPower) local ballDestY = ball.y + (ballVelY * C.BattingPower) - -- Hit! + 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) + if utils.isFoulBall(ballDestX, ballDestY) then + announcer:say("Foul ball!") + pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2) + -- TODO: Have a fielder chase for the fly-out + return + end + baserunning.batter.nextBase = C.Bases[C.First] baserunning.batter.prevBase = C.Bases[C.Home] baserunning:updateForcedRunners() @@ -307,14 +307,14 @@ end ---@param appliedSpeed number ---@return boolean someRunnerMoved local function updateNonBatterRunners(appliedSpeed, forcedOnly) - local runnerMoved, runnersScored = baserunning:updateRunning(appliedSpeed, forcedOnly, deltaSeconds) + local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds) if runnersScored ~= 0 then score(runnersScored) end return runnerMoved end -local function playerPitch(throwFly) +local function userPitch(throwFly) local aButton = playdate.buttonIsPressed(playdate.kButtonA) local bButton = playdate.buttonIsPressed(playdate.kButtonB) if not aButton and not bButton then @@ -348,11 +348,10 @@ local function updateGameState() ball.size = ball.sizeAnimator:currentValue() end - local playerOnOffense, playerOnDefense = playerIsOn(C.Sides.offense) + local userOnOffense, userOnDefense = userIsOn(C.Sides.offense) - if playerOnDefense then - throwMeter = math.max(0, throwMeter - (deltaSeconds * C.ThrowMeterDrainPerSec)) - throwMeter = throwMeter + math.abs(crankLimited * C.PitchPower) + if userOnDefense then + throwMeter:applyCharge(deltaSeconds, crankLimited) end if offenseState == C.Offense.batting then @@ -363,23 +362,24 @@ local function updateGameState() end local pitcher = fielding.fielders.pitcher - if utils.distanceBetween(pitcher.x, pitcher.y, C.PitchStartX, C.PitchStartY) < C.BaseHitbox then + if utils.distanceBetween(pitcher.x, pitcher.y, C.PitcherStartPos.x, C.PitcherStartPos.y) < C.BaseHitbox then secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds end - if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then + if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then local outcome = pitchTracker:updatePitchCounts() if outcome == PitchOutcomes.StrikeOut then strikeOut() elseif outcome == PitchOutcomes.Walk then walk() end + -- Catcher has the ball. Throw it back to the pitcher launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true) catcherThrownBall = true end local batSpeed - if playerOnOffense then + if userOnOffense then batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360 batSpeed = crankLimited else @@ -394,25 +394,27 @@ local function updateGameState() baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds) if secondsSincePitchAllowed > C.PitchAfterSeconds then - if playerOnDefense then - local throwFly = readThrow() + if userOnDefense then + local throwFly = throwMeter:readThrow() if throwFly and not buttonControlledThrow(throwFly, true) then - playerPitch(throwFly) + userPitch(throwFly) end else - pitch(C.PitchFlyMs, math.random(#Pitches)) + pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches)) end end elseif offenseState == C.Offense.running then - local appliedSpeed = playerOnOffense and crankLimited or npc:runningSpeed() + local appliedSpeed = userOnOffense and crankLimited or npc:runningSpeed() 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:resetFielderPositions() offenseState = C.Offense.batting + -- TODO: Remove, or replace with nextBatter() if not baserunning.batter then baserunning.batter = baserunning:newRunner() end @@ -426,15 +428,15 @@ local function updateGameState() local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds) - if playerOnDefense then - local throwFly = readThrow() + if userOnDefense then + local throwFly = throwMeter:readThrow() if throwFly then buttonControlledThrow(throwFly) end end if fielderHoldingBall then local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) - if playerOnOffense then + if userOnOffense then npc:fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, launchBall) end end @@ -467,10 +469,6 @@ function playdate.update() gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y) end - if playdate.isCrankDocked() then - playdate.ui.crankIndicator:draw() - end - local playerHeightOffset = 10 -- TODO? Scale sprites down as y increases for _, runner in pairs(baserunning.runners) do @@ -507,6 +505,10 @@ function playdate.update() drawScoreboard(0, C.Screen.H * 0.77, teams, baserunning.outs, battingTeam, inning) drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes) announcer:draw(C.Center.x, 10) + + if playdate.isCrankDocked() then + playdate.ui.crankIndicator:draw() + end end local function init() diff --git a/src/npc.lua b/src/npc.lua index 6c32542..f649de0 100644 --- a/src/npc.lua +++ b/src/npc.lua @@ -133,6 +133,11 @@ function Npc:fielderAction(offenseState, fielder, outedSomeRunner, ball, launchB end end +---@return number +function Npc:pitchSpeed() + return 2 +end + if not playdate then return Npc end diff --git a/src/utils.lua b/src/utils.lua index 8300750..8357bb6 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -22,16 +22,25 @@ function utils.easingHill(t, b, c, d) return (c * t) + b end +--- @alias StaticAnimator { +--- currentValue: fun(self): number; +--- reset: fun(self, durationMs: number | nil); +--- ended: fun(self): boolean; +--- } + --- Build an "animator" whose `:currentValue()` always returns the given value. --- Essentially an "empty object" pattern for initial object positions. ---@param value number ----@return SimpleAnimator +---@return StaticAnimator function utils.staticAnimator(value) return { currentValue = function(_) return value end, reset = function(_) end, + ended = function(_) + return true + end, } end @@ -143,7 +152,7 @@ function utils.getRunnerWithNextBase(runners, base) end) end ---- Returns true only if the point is below the given line, within the x bounds of said line, and above the bottomBound +--- Returns true only if the point is below the given line, within the x bounds of said line, and above the bottomBound. ---@return boolean function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2, bottomBound) -- This check currently assumes right-handedness. @@ -152,6 +161,12 @@ function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, li return false end + return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2) +end + +--- Returns true only if the point is below the given line. +---@return boolean +function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2) local m = (lineY2 - lineY1) / (lineX2 - lineX1) -- y = mx + b @@ -164,8 +179,19 @@ function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, li return yDelta <= 0 end +--- Returns true if a ball landing at destX,destY will be foul. +---@param destX number +---@param destY number +function utils.isFoulBall(destX, destY) + local leftLine = C.LeftFoulLine + local rightLine = C.RightFoulLine + + return utils.pointUnderLine(destX, destY, leftLine.x1, leftLine.y1, leftLine.x2, leftLine.y2) + or utils.pointUnderLine(destX, destY, rightLine.x1, rightLine.y1, rightLine.x2, rightLine.y2) +end + --- Returns the nearest position object from the given point, as well as its distance from that point ----@generic T : {x: number, y: number | nil} +---@generic T : { x: number, y: number | nil } ---@param array T[] ---@param x number ---@param y number @@ -231,6 +257,35 @@ pitchTracker = { end, } -if not playdate then - return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes } +----------------- +-- 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)) + 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) +end + +if not playdate then + return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes, throwMeter = throwMeter } end