BatterUp/src/main.lua

750 lines
23 KiB
Lua

-- stylua: ignore start
import 'CoreLibs/animation.lua'
import 'CoreLibs/animator.lua'
import 'CoreLibs/easing.lua'
import 'CoreLibs/graphics.lua'
import 'CoreLibs/object.lua'
import 'CoreLibs/timer.lua'
import 'CoreLibs/ui.lua'
--- @alias XYPair {
--- x: number,
--- y: number,
--- }
--- @alias Base {
--- x: number,
--- y: number,
--- }
--- @alias Runner {
--- x: number,
--- y: number,
--- nextBase: Base,
--- prevBase: Base | nil,
--- forcedTo: Base | nil,
--- }
--- @alias Fielder {
--- x: number | nil,
--- y: number | nil,
--- target: XYPair | nil,
--- speed: number,
--- }
import 'announcer.lua'
import 'graphics.lua'
import 'scoreboard.lua'
import 'utils.lua'
-- stylua: ignore end
local gfx <const> = playdate.graphics
local Screen <const> = {
W = playdate.display.getWidth(),
H = playdate.display.getHeight(),
}
local Center <const> = xy(Screen.W / 2, Screen.H / 2)
local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune.wav")
-- local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune-organy.wav")
local TinnyBackground <const> = playdate.sound.sampleplayer.new("sounds/tinny-background.wav")
local BatCrackSound <const> = playdate.sound.sampleplayer.new("sounds/bat-crack-reverb.wav")
local GrassBackground <const> = gfx.image.new("images/game/grass.png") --[[@as pd_image]]
local PlayerFrown <const> = gfx.image.new("images/game/player-frown.png") --[[@as pd_image]]
local PlayerSmile <const> = gfx.image.new("images/game/player.png") --[[@as pd_image]]
local PlayerBack <const> = gfx.image.new("images/game/player-back.png") --[[@as pd_image]]
local PlayerImageBlipper <const> = blipper.new(100, "images/game/player.png", "images/game/player-lowhat.png")
local DanceBounceMs <const> = 500
local DanceBounceCount <const> = 4
local FielderDanceAnimator <const> = gfx.animator.new(1, 10, 0, easingHill)
FielderDanceAnimator.repeatCount = DanceBounceCount - 1
-- selene: allow(unused_variable)
function fieldersDance()
FielderDanceAnimator:reset(DanceBounceMs)
end
local BallOffscreen <const> = 999
local PitchFlyMs <const> = 1050
local PitchStartX <const> = 195
local PitchStartY <const>, PitchEndY <const> = 105, 240
local ballAnimatorY = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear)
local ballAnimatorX = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear)
local Pitches <const> = {
-- Fastball
{
x = gfx.animator.new(0, PitchStartX, PitchStartX, playdate.easingFunctions.linear),
y = gfx.animator.new(PitchFlyMs / 1.3, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
},
-- Slider
{
x = gfx.animator.new(PitchFlyMs, PitchStartX - 20, PitchStartX, easingHill),
y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
},
-- Curve ball
{
x = gfx.animator.new(PitchFlyMs, PitchStartX + 20, PitchStartX, easingHill),
y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
},
-- Wobbbleball
{
x = {
currentValue = function()
return PitchStartX + (10 * math.sin((ballAnimatorY:currentValue() - PitchStartY) / 10))
end,
reset = function() end,
},
y = gfx.animator.new(PitchFlyMs * 1.3, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
},
}
local CrankOffsetDeg <const> = 90
local BatOffset <const> = xy(10, 25)
local batBase <const> = xy(Center.x - 34, 215)
local batTip <const> = xy(0, 0)
local TagDistance <const> = 20
local SmallestBallRadius <const> = 6
local ball <const> = {
x = Center.x --[[@as number]],
y = Center.y --[[@as number]],
size = SmallestBallRadius,
heldBy = nil --[[@type Runner | nil]],
}
local BatLength <const> = 50 --45
local Modes <const> = {
batting = {},
running = {},
}
local currentMode = Modes.batting
-- 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, easingHill)
local BallSizeMs = 2000
local ballSizeAnimator = gfx.animator.new(BallSizeMs, 9, SmallestBallRadius, easingHill)
local HitMult = 20
local deltaSeconds = 0
local First <const>, Second <const>, Third <const>, Home <const> = 1, 2, 3, 4
---@type Base[]
local Bases = {
xy(Screen.W * 0.93, Screen.H * 0.52),
xy(Screen.W * 0.47, Screen.H * 0.19),
xy(Screen.W * 0.03, Screen.H * 0.52),
xy(Screen.W * 0.474, Screen.H * 0.79),
}
-- Pseudo-base for batter to target
local RightHandedBattersBox <const> = xy(Bases[Home].x - 35, Bases[Home].y)
---@type table<Base, Base>
local NextBaseMap <const> = {
[Bases[First]] = Bases[Second],
[Bases[Second]] = Bases[Third],
[Bases[Third]] = Bases[Home],
}
function newFielder(name, speed)
return {
name = name,
speed = speed,
}
end
---@type table<string, Fielder>
local fielders <const> = {
first = newFielder("First", 40),
second = newFielder("Second", 40),
shortstop = newFielder("Shortstop", 40),
third = newFielder("Third", 40),
pitcher = newFielder("Pitcher", 30),
catcher = newFielder("Catcher", 20),
left = newFielder("Left", 40),
center = newFielder("Center", 40),
right = newFielder("Right", 40),
}
local PitcherStartPos <const> = {
x = Screen.W * 0.48,
y = Screen.H * 0.40,
}
--- Resets the target positions of all fielders to their defaults (at their field positions).
---@param fromOffTheField boolean 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 = Center.x
fielder.y = Screen.H
end
end
fielders.first.target = xy(Screen.W - 65, Screen.H * 0.48)
fielders.second.target = xy(Screen.W * 0.70, Screen.H * 0.30)
fielders.shortstop.target = xy(Screen.W * 0.30, Screen.H * 0.30)
fielders.third.target = xy(Screen.W * 0.1, Screen.H * 0.48)
fielders.pitcher.target = xy(PitcherStartPos.x, PitcherStartPos.y)
fielders.catcher.target = xy(Screen.W * 0.475, Screen.H * 0.92)
fielders.left.target = xy(Screen.W * -1, Screen.H * -0.2)
fielders.center.target = xy(Center.x, Screen.H * -0.4)
fielders.right.target = xy(Screen.W * 2, fielders.left.target.y)
end
local BatterStartingX <const> = Bases[Home].x - 40
local BatterStartingY <const> = Bases[Home].y - 3
--- @type Runner[]
local runners <const> = {}
--- @type Runner[]
local outRunners <const> = {}
---@return Runner
function newRunner()
local new = {
x = BatterStartingX - 60,
y = BatterStartingY + 60,
nextBase = RightHandedBattersBox,
prevBase = nil,
forcedTo = Bases[First],
}
runners[#runners + 1] = new
return new
end
---@type Runner | nil
local batter = newRunner()
--- "Throws" the ball from its current position to the given destination.
function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
if not flyTimeMs then
flyTimeMs = distanceBetween(ball.x, ball.y, destX, destY) * 5
end
ball.heldBy = nil
if customBallScaler then
ballSizeAnimator = customBallScaler
else
-- TODO? Scale based on distance?
ballSizeAnimator = gfx.animator.new(flyTimeMs, 9, SmallestBallRadius, easingHill)
end
ballAnimatorY = gfx.animator.new(flyTimeMs, ball.y, destY, easingFunc)
ballAnimatorX = gfx.animator.new(flyTimeMs, ball.x, destX, easingFunc)
if floaty then
ballFloatAnimator:reset(flyTimeMs)
end
end
local PitchAfterSeconds = 7
-- TODO: Replace with a timer, repeatedly reset instead of setting to 0
local secondsSincePitchAllowed = -5
local catcherThrownBall = false
function pitch()
catcherThrownBall = false
currentMode = Modes.batting
local current = Pitches[math.random(#Pitches)]
ballAnimatorX = current.x
ballAnimatorY = current.y or Pitches[1].y
-- TODO: This would need to be sanely replaced in throwBall() etc.
-- if current.z then
-- ballFloatAnimator = current.z
-- ballFloatAnimator:reset()
-- end
ballAnimatorX:reset()
ballAnimatorY:reset()
secondsSincePitchAllowed = 0
end
local elapsedSec = 0
local crankChange = 0
local acceleratedChange
local BaseHitbox = 13
--- Returns the base being touched by the runner at (x,y), or nil, if no base is being touched
function isTouchingBase(x, y)
for _, base in ipairs(Bases) do
if distanceBetween(x, y, base.x, base.y) < BaseHitbox then
return base
end
end
return nil
end
local BallCatchHitbox = 3
function isTouchingBall(x, y)
local ballDistance = distanceBetween(x, y, ball.x, ball.y)
return ballDistance < BallCatchHitbox
end
local teams <const> = {
home = {
score = 0,
},
away = {
score = 0,
},
}
local battingTeam = teams.away
local outs = 0
local inning = 1
---@param base Base
---@return Runner | nil
function getRunnerTargeting(base)
for _, runner in pairs(runners) do
if runner.nextBase == base then
return runner
end
end
return nil
end
function updateForcedRunners()
local stillForced = true
for _, base in ipairs(Bases) do
local runnerTargetingBase = getRunnerTargeting(base)
if runnerTargetingBase then
if stillForced then
runnerTargetingBase.forcedTo = base
else
runnerTargetingBase.forcedTo = nil
end
else
stillForced = false
end
end
end
---@param runnerIndex integer
function outRunner(runnerIndex)
outs = outs + 1
outRunners[#outRunners + 1] = runners[runnerIndex]
table.remove(runners, runnerIndex)
updateForcedRunners()
announcer:say("YOU'RE OUT!")
if outs == 3 then
local gameOver = inning == 9 and teams.away.score ~= teams.home.score
if not gameOver then
fieldersDance()
announcer:say("SWITCHING SIDES...")
end
while #runners > 0 do
outRunners[#outRunners + 1] = table.remove(runners, #runners)
end
-- Delay to keep end-of-inning on the scoreboard for a few seconds
playdate.timer.new(3000, function()
outs = 0
if battingTeam == teams.home then
battingTeam = teams.away
inning = inning + 1
else
battingTeam = teams.home
end
if gameOver then
announcer:say("AND THAT'S THE BALL GAME!")
end
end)
end
end
-- TODO: Away score
function score(runnerIndex)
outRunners[#outRunners + 1] = runners[runnerIndex]
table.remove(runners, runnerIndex)
battingTeam.score = battingTeam.score + 1
announcer:say("SCORE!")
end
---@return Base[]
function getForcedOutTargets()
local targets = {}
for _, base in ipairs(Bases) do
local runnerTargetingBase = getRunnerTargeting(base)
if runnerTargetingBase then
targets[#targets + 1] = base
else
return targets
end
end
return targets
end
--- Returns the position,distance of the basest closest to the runner furthest from a base
---@return Base | nil, number | nil
function getBaseOfStrandedRunner()
local farRunnersBase, farDistance
for _, runner in pairs(runners) do
local nearestBase, distance = getNearestOf(Bases, runner.x, runner.y, function(base)
return runner.nextBase == base
end)
if farRunnersBase == nil or farDistance < distance then
farRunnersBase = nearestBase
farDistance = distance
end
end
return farRunnersBase, farDistance
end
--- Returns x,y of the throw target
---@return number|nil, number|nil
function getNextThrowTarget()
-- TODO: Handle missed throws, check for fielders at target, etc.
local targets = getForcedOutTargets()
if #targets ~= 0 then
return targets[#targets].x, targets[#targets].y
end
local baseCloseToStrandedRunner = getBaseOfStrandedRunner()
if baseCloseToStrandedRunner then
return baseCloseToStrandedRunner.x, baseCloseToStrandedRunner.y
end
end
function tryToThrowOut(thrower)
local targetX, targetY = getNextThrowTarget()
if targetX ~= nil and targetY ~= nil then
local nearestFielder = getNearestOf(fielders, targetX, targetY)
nearestFielder.target = xy(targetX, targetY)
if nearestFielder == thrower then
ball.heldBy = thrower
else
throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
end
end
end
function updateFielders()
local touchingBaseCache = buildCache(function(runner)
return isTouchingBase(runner.x, runner.y)
end)
for _, fielder in pairs(fielders) do
-- TODO: Target unforced runners (or their target bases) for tagging
-- With new Position-based scheme, fielders are now able to set `fielder.target = runner` to track directly
if fielder.target ~= nil then
local x, y, distance = normalizeVector(fielder.x, fielder.y, fielder.target.x, fielder.target.y)
if distance > 1 then
fielder.x = fielder.x - (x * fielder.speed * deltaSeconds)
fielder.y = fielder.y - (y * fielder.speed * deltaSeconds)
else
fielder.target = nil
end
end
if currentMode == Modes.running and isTouchingBall(fielder.x, fielder.y) then
-- TODO: Check for double-plays or other available outs.
local touchedBase = isTouchingBase(fielder.x, fielder.y)
for i, runner in pairs(runners) do
if
( -- Force out
touchedBase
and runner.prevBase -- Make sure the runner is not standing at home
and runner.forcedTo == touchedBase
and touchedBase ~= touchingBaseCache.get(runner)
)
or ( -- Tag out
not touchingBaseCache.get(runner)
and distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < TagDistance
)
then
outRunner(i)
playdate.timer.new(750, function()
tryToThrowOut(fielder)
end)
else
tryToThrowOut(fielder)
end
end
end
end
end
--- Returns true if at least one runner is still moving
---@return boolean
function updateRunners(currentRunners)
local autoRunSpeed = 20 * deltaSeconds
--autoRunSpeed = 140
-- TODO: Filter for the runner closest to the currently-held direction button
local runnerMoved = false
for runnerIndex, runner in ipairs(currentRunners) do
local appliedSpeed = crankChange -- TODO: Allow for individual runner control via buttons
local nearestBase, nearestBaseDistance = getNearestOf(Bases, runner.x, runner.y)
if
nearestBaseDistance < 5
and runner.prevBase
and runner.nextBase == Bases[Home]
and nearestBase == Bases[Home]
then
score(runnerIndex)
end
if runner.nextBase then
local nb = runner.nextBase
local x, y, distance = normalizeVector(runner.x, runner.y, nb.x, nb.y)
if distance > 1 then
local prevX, prevY = runner.x, runner.y
local mult = 1
if appliedSpeed < 0 then
if runner.prevBase then
mult = -1
else
-- Don't allow running backwards when approaching the plate
appliedSpeed = 0
end
end
-- TODO: Also move if forced to 😅
local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
or nearestBaseDistance < 5 and 0
or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
mult = autoRun + (appliedSpeed / 20)
runner.x = runner.x - (x * mult)
runner.y = runner.y - (y * mult)
runnerMoved = runnerMoved or prevX ~= runner.x or prevY ~= runner.y
else
runner.nextBase = NextBaseMap[runner.nextBase]
runner.forcedTo = nil
end
end
end
return runnerMoved
end
local ResetFieldersAfterSeconds = 2
-- TODO: Replace with a timer, repeatedly reset instead of setting to 0
local secondsSinceLastRunnerMove = 0
function init()
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
resetFielderPositions(true)
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
playdate.timer.new(2000, function()
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, false)
end)
BootTune:play()
BootTune:setFinishCallback(function()
TinnyBackground:play()
end)
end
local batAngleDeg
function updateBatting()
if ball.y < BallOffscreen then
ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue()
ball.size = SmallestBallRadius -- ballFloatAnimator:currentValue()
end
batAngleDeg = (playdate.getCrankPosition() + CrankOffsetDeg) % 360
local batAngle = math.rad(batAngleDeg)
-- TODO: animate bat-flip or something
batBase.x = batter and (batter.x + BatOffset.x) or 0
batBase.y = batter and (batter.y + BatOffset.y) or 0
batTip.x = batBase.x + (BatLength * math.sin(batAngle))
batTip.y = batBase.y + (BatLength * math.cos(batAngle))
if
acceleratedChange >= 0
and pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H)
then
BatCrackSound:play()
currentMode = Modes.running
local ballAngle = batAngle + math.rad(90)
local mult = math.abs(crankChange / 15)
local ballVelX = mult * 10 * math.sin(ballAngle)
local ballVelY = mult * 5 * math.cos(ballAngle)
if ballVelY > 0 then
ballVelX = ballVelX * -1
ballVelY = ballVelY * -1
end
local ballDestX = ball.x + (ballVelX * HitMult)
local ballDestY = ball.y + (ballVelY * HitMult)
-- Hit!
throwBall(
ballDestX,
ballDestY,
playdate.easingFunctions.outQuint,
2000,
nil,
gfx.animator.new(2000, 9 + (mult * mult * 0.5), SmallestBallRadius, easingHill)
)
fielders.first.target = Bases[First]
batter.nextBase = Bases[First]
batter.prevBase = Bases[Home]
updateForcedRunners()
batter.forcedTo = Bases[First]
batter = nil -- Demote batter to a mere runner
local chasingFielder = getNearestOf(fielders, ballDestX, ballDestY)
chasingFielder.target = { x = ballDestX, y = ballDestY }
end
end
function updateRunning()
local nonBatterRunners = filter(runners, function(runner)
return runner ~= batter
end)
ball.size = ballSizeAnimator:currentValue()
if updateRunners(nonBatterRunners) then
secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
resetFielderPositions(false)
currentMode = Modes.batting
batter = newRunner()
end
end
end
function updateOutRunners()
for i, runner in ipairs(outRunners) do
if runner.x < Screen.W + 50 and runner.y < Screen.H + 50 then
runner.x = runner.x + (deltaSeconds * 25)
runner.y = runner.y + (deltaSeconds * 25)
else
table.remove(outRunners, i)
end
end
end
function updateGameState()
deltaSeconds = playdate.getElapsedTime() or 0
playdate.resetElapsedTime()
elapsedSec = elapsedSec + deltaSeconds
crankChange, acceleratedChange = playdate.getCrankChange() --[[@as number, number]]
if ball.heldBy then
ball.x = ball.heldBy.x
ball.y = ball.heldBy.y
else
ball.x = ballAnimatorX:currentValue()
ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue()
end
if currentMode == Modes.batting then
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
catcherThrownBall = true
end
if secondsSincePitchAllowed > PitchAfterSeconds then
pitch()
end
updateBatting()
updateRunners({ batter })
elseif currentMode == Modes.running then
updateRunning()
end
updateFielders()
updateOutRunners()
end
-- TODO
function drawMinimap() end
function playdate.update()
playdate.timer.updateTimers()
updateGameState()
gfx.animation.blinker.updateAll()
gfx.clear()
gfx.setColor(gfx.kColorBlack)
local offsetX, offsetY = 0, 0
if ball.x < BallOffscreen then
offsetX, offsetY = getDrawOffset(Screen.W, Screen.H, ball.x, ball.y)
gfx.setDrawOffset(offsetX, offsetY)
end
GrassBackground:draw(-400, -240)
local fielderDanceHeight = FielderDanceAnimator:currentValue()
for _, fielder in pairs(fielders) do
gfx.fillRect(fielder.x, fielder.y - fielderDanceHeight, 14, 25)
end
if currentMode == Modes.batting then
gfx.setLineWidth(5)
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
end
if playdate.isCrankDocked() then
playdate.ui.crankIndicator:draw()
end
-- TODO? Change blip speed depending on runner speed?
for _, runner in pairs(runners) do
if runner == batter then
if batAngleDeg > 50 and batAngleDeg < 200 then
PlayerBack:draw(runner.x, runner.y)
else
PlayerSmile:draw(runner.x, runner.y)
end
else
-- TODO? Scale sprites down as y increases
PlayerImageBlipper:draw(false, runner.x, runner.y)
end
end
for _, runner in pairs(outRunners) do
PlayerFrown:draw(runner.x, runner.y)
end
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)
gfx.setDrawOffset(0, 0)
if offsetX > 0 or offsetY > 0 then
drawMinimap()
end
drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning)
announcer:draw(Center.x, 10)
end
init()