1038 lines
31 KiB
Lua
1038 lines
31 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,
|
|
--- y: number,
|
|
--- target: XYPair | nil,
|
|
--- speed: number,
|
|
--- }
|
|
|
|
--- @alias EasingFunc fun(number, number, number, number): number
|
|
|
|
import 'announcer.lua'
|
|
import 'dbg.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> = utils.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 Minimap <const> = gfx.image.new("images/game/minimap.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 Glove <const> = gfx.image.new("images/game/glove.png") --[[@as pd_image]]
|
|
local GloveHoldingBall <const> = gfx.image.new("images/game/glove-holding-ball.png") --[[@as pd_image]]
|
|
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
|
|
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
|
|
|
|
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, utils.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)
|
|
|
|
---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
|
|
---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil }
|
|
|
|
---@type Pitch[]
|
|
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),
|
|
},
|
|
-- Curve ball
|
|
{
|
|
x = gfx.animator.new(PitchFlyMs, PitchStartX + 20, PitchStartX, utils.easingHill),
|
|
y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
|
|
},
|
|
-- Slider
|
|
{
|
|
x = gfx.animator.new(PitchFlyMs, PitchStartX - 20, PitchStartX, utils.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> = utils.xy(10, 25)
|
|
|
|
local batBase <const> = utils.xy(Center.x - 34, 215)
|
|
local batTip <const> = utils.xy(0, 0)
|
|
|
|
local TagDistance <const> = 15
|
|
|
|
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
|
|
|
|
local Offense <const> = {
|
|
batting = {},
|
|
running = {},
|
|
}
|
|
|
|
local Sides <const> = {
|
|
offense = {},
|
|
defense = {},
|
|
}
|
|
|
|
local offenseMode = Offense.batting
|
|
|
|
---@alias Team { score: number, benchPosition: XYPair }
|
|
|
|
---@type table<string, Team>
|
|
local teams <const> = {
|
|
home = {
|
|
score = 0,
|
|
benchPosition = utils.xy(Screen.W + 10, Center.y),
|
|
},
|
|
away = {
|
|
score = 0,
|
|
benchPosition = utils.xy(-10, Center.y),
|
|
},
|
|
}
|
|
|
|
local PlayerTeam <const> = teams.home
|
|
local battingTeam = teams.away
|
|
local outs = 0
|
|
local inning = 1
|
|
|
|
---@return boolean playerIsOnSide, boolean playerIsOnOtherSide
|
|
function playerIsOn(side)
|
|
local ret
|
|
if PlayerTeam == battingTeam then
|
|
ret = side == Sides.offense
|
|
else
|
|
ret = side == Sides.defense
|
|
end
|
|
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, SmallestBallRadius, utils.easingHill)
|
|
|
|
local HitMult = 20
|
|
|
|
local deltaSeconds = 0
|
|
|
|
local First <const>, Second <const>, Third <const>, Home <const> = 1, 2, 3, 4
|
|
|
|
---@type Base[]
|
|
local Bases = {
|
|
utils.xy(Screen.W * 0.93, Screen.H * 0.52),
|
|
utils.xy(Screen.W * 0.47, Screen.H * 0.19),
|
|
utils.xy(Screen.W * 0.03, Screen.H * 0.52),
|
|
utils.xy(Screen.W * 0.474, Screen.H * 0.79),
|
|
}
|
|
|
|
-- Pseudo-base for batter to target
|
|
local RightHandedBattersBox <const> = utils.xy(Bases[Home].x - 35, Bases[Home].y)
|
|
|
|
---@type table<Base, Base | nil>
|
|
local NextBaseMap <const> = {
|
|
[RightHandedBattersBox] = nil, -- Runner should not escape the box before a hit!
|
|
[Bases[First]] = Bases[Second],
|
|
[Bases[Second]] = Bases[Third],
|
|
[Bases[Third]] = Bases[Home],
|
|
}
|
|
|
|
---@param name string
|
|
---@param speed number
|
|
---@return Fielder
|
|
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", 35),
|
|
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,
|
|
}
|
|
|
|
--- 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(Screen.W - 65, Screen.H * 0.48)
|
|
fielders.second.target = utils.xy(Screen.W * 0.70, Screen.H * 0.30)
|
|
fielders.shortstop.target = utils.xy(Screen.W * 0.30, Screen.H * 0.30)
|
|
fielders.third.target = utils.xy(Screen.W * 0.1, Screen.H * 0.48)
|
|
fielders.pitcher.target = utils.xy(PitcherStartPos.x, PitcherStartPos.y)
|
|
fielders.catcher.target = utils.xy(Screen.W * 0.475, Screen.H * 0.92)
|
|
fielders.left.target = utils.xy(Screen.W * -1, Screen.H * -0.2)
|
|
fielders.center.target = utils.xy(Center.x, Screen.H * -0.4)
|
|
fielders.right.target = utils.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()
|
|
|
|
local throwMeter = 0
|
|
local PitchMeterLimit = 15
|
|
|
|
--- "Throws" the ball from its current position to the given destination.
|
|
---@param destX number
|
|
---@param destY number
|
|
---@param easingFunc EasingFunc
|
|
---@param flyTimeMs number | nil
|
|
---@param floaty boolean | nil
|
|
---@param customBallScaler pd_animator | nil
|
|
function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
|
|
if not flyTimeMs then
|
|
flyTimeMs = utils.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, utils.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
|
|
throwMeter = 0
|
|
end
|
|
|
|
local PitchAfterSeconds = 7
|
|
-- 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)
|
|
catcherThrownBall = false
|
|
offenseMode = Offense.batting
|
|
|
|
local current = Pitches[pitchTypeIndex]
|
|
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
|
|
|
|
if pitchFlyTimeMs then
|
|
ballAnimatorX:reset(pitchFlyTimeMs)
|
|
ballAnimatorY:reset(pitchFlyTimeMs)
|
|
else
|
|
ballAnimatorX:reset()
|
|
ballAnimatorY:reset()
|
|
end
|
|
|
|
secondsSincePitchAllowed = 0
|
|
end
|
|
|
|
local BaseHitbox = 10
|
|
|
|
--- Returns the base being touched by the player at (x,y), or nil, if no base is being touched
|
|
---@param x number
|
|
---@param y number
|
|
---@return Base | nil
|
|
function isTouchingBase(x, y)
|
|
return utils.first(Bases, function(base)
|
|
return utils.distanceBetween(x, y, base.x, base.y) < BaseHitbox
|
|
end)
|
|
end
|
|
|
|
local BallCatchHitbox = 3
|
|
|
|
--- Returns true only if the given point is touching the ball at its current position
|
|
---@param x number
|
|
---@param y number
|
|
---@return boolean, number
|
|
function isTouchingBall(x, y)
|
|
local ballDistance = utils.distanceBetween(x, y, ball.x, ball.y)
|
|
return ballDistance < BallCatchHitbox, ballDistance
|
|
end
|
|
|
|
---@param base Base
|
|
---@return Runner | nil
|
|
function getRunnerWithNextBase(base)
|
|
return utils.first(runners, function(runner)
|
|
return runner.nextBase == base
|
|
end)
|
|
end
|
|
|
|
function updateForcedRunners()
|
|
local stillForced = true
|
|
for _, base in ipairs(Bases) do
|
|
local runnerTargetingBase = getRunnerWithNextBase(base)
|
|
if runnerTargetingBase then
|
|
if stillForced then
|
|
runnerTargetingBase.forcedTo = base
|
|
else
|
|
runnerTargetingBase.forcedTo = nil
|
|
end
|
|
else
|
|
stillForced = false
|
|
end
|
|
end
|
|
end
|
|
|
|
local ResetFieldersAfterSeconds <const> = 5
|
|
-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0
|
|
local secondsSinceLastRunnerMove = 0
|
|
|
|
---@param runnerIndex integer
|
|
function outRunner(runnerIndex)
|
|
outRunners[#outRunners + 1] = runners[runnerIndex]
|
|
table.remove(runners, runnerIndex)
|
|
|
|
outs = outs + 1
|
|
updateForcedRunners()
|
|
|
|
announcer:say("YOU'RE OUT!")
|
|
if outs == 3 then
|
|
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()
|
|
secondsSinceLastRunnerMove = -7
|
|
benchAllFielders(currentlyFieldingTeam.benchPosition)
|
|
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
|
|
battingTeam = currentlyFieldingTeam
|
|
if gameOver then
|
|
announcer:say("AND THAT'S THE BALL GAME!")
|
|
else
|
|
if battingTeam == teams.home then
|
|
inning = inning + 1
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
---@param runnerIndex number
|
|
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 = getRunnerWithNextBase(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
|
|
if runner ~= batter then
|
|
local nearestBase, distance = utils.getNearestOf(Bases, runner.x, runner.y)
|
|
if farRunnersBase == nil or farDistance < distance then
|
|
farRunnersBase = nearestBase
|
|
farDistance = distance
|
|
end
|
|
end
|
|
end
|
|
|
|
return farRunnersBase, farDistance
|
|
end
|
|
|
|
--- Returns x,y of the out target
|
|
---@return number|nil, number|nil
|
|
function getNextOutTarget()
|
|
-- 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
|
|
|
|
---@param fielder Fielder
|
|
function tryToMakeAnOut(fielder)
|
|
local targetX, targetY = getNextOutTarget()
|
|
if targetX ~= nil and targetY ~= nil then
|
|
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
|
|
nearestFielder.target = utils.xy(targetX, targetY)
|
|
if nearestFielder == fielder then
|
|
ball.heldBy = fielder
|
|
else
|
|
throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
|
|
end
|
|
end
|
|
end
|
|
|
|
function readThrow()
|
|
if throwMeter > PitchMeterLimit then
|
|
return (PitchFlyMs / (throwMeter / PitchMeterLimit))
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@param thrower Fielder
|
|
---@param throwFlyMs number
|
|
---@return boolean didThrow
|
|
function buttonControlledThrow(thrower, throwFlyMs, forbidThrowHome)
|
|
local targetBase
|
|
if playdate.buttonIsPressed(playdate.kButtonLeft) then
|
|
targetBase = Bases[Third]
|
|
elseif playdate.buttonIsPressed(playdate.kButtonUp) then
|
|
targetBase = Bases[Second]
|
|
elseif playdate.buttonIsPressed(playdate.kButtonRight) then
|
|
targetBase = Bases[First]
|
|
elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
|
|
targetBase = Bases[Home]
|
|
else
|
|
return false
|
|
end
|
|
|
|
local closestFielder = utils.getNearestOf(fielders, targetBase.x, targetBase.y, function(fielder)
|
|
return fielder ~= thrower
|
|
end)
|
|
|
|
throwBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
|
|
closestFielder.target = targetBase
|
|
secondsSinceLastRunnerMove = 0
|
|
offenseMode = Offense.running
|
|
|
|
return true
|
|
end
|
|
|
|
function outEligibleRunners(fielder)
|
|
local touchedBase = isTouchingBase(fielder.x, fielder.y)
|
|
local didOutRunner = false
|
|
for i, runner in pairs(runners) do
|
|
local runnerOnBase = isTouchingBase(runner.x, runner.y)
|
|
if -- Force out
|
|
touchedBase
|
|
and runner.prevBase -- Make sure the runner is not standing at home
|
|
and runner.forcedTo == touchedBase
|
|
and touchedBase ~= runnerOnBase
|
|
-- Tag out
|
|
or not runnerOnBase and utils.distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < TagDistance
|
|
then
|
|
outRunner(i)
|
|
didOutRunner = true
|
|
end
|
|
end
|
|
|
|
return didOutRunner
|
|
end
|
|
|
|
function updateNpcFielder(fielder, outedSomeRunner)
|
|
if offenseMode ~= Offense.running then
|
|
return
|
|
end
|
|
if outedSomeRunner then
|
|
playdate.timer.new(750, function()
|
|
tryToMakeAnOut(fielder)
|
|
end)
|
|
else
|
|
tryToMakeAnOut(fielder)
|
|
end
|
|
end
|
|
|
|
---@param fielder Fielder
|
|
function updateFielder(fielder)
|
|
if fielder.target ~= nil then
|
|
if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
|
|
fielder.target = nil
|
|
end
|
|
end
|
|
|
|
if not isTouchingBall(fielder.x, fielder.y) then
|
|
return
|
|
end
|
|
|
|
local outedSomeRunner = outEligibleRunners(fielder)
|
|
|
|
if playerIsOn(Sides.defense) then
|
|
local throwFly = readThrow()
|
|
if throwFly then
|
|
buttonControlledThrow(fielders.pitcher, throwFly)
|
|
end
|
|
else
|
|
updateNpcFielder(fielder, outedSomeRunner)
|
|
end
|
|
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 appliedSpeed number
|
|
---@return boolean
|
|
function updateRunner(runner, runnerIndex, appliedSpeed)
|
|
local autoRunSpeed = 20 * deltaSeconds
|
|
--autoRunSpeed = 140
|
|
|
|
if not runner or not runner.nextBase then
|
|
return false
|
|
end
|
|
|
|
local nearestBase, nearestBaseDistance = utils.getNearestOf(Bases, runner.x, runner.y)
|
|
|
|
if
|
|
nearestBaseDistance < 5
|
|
and runnerIndex ~= nil
|
|
and runner ~= batter --runner.prevBase
|
|
and runner.nextBase == Bases[Home]
|
|
and nearestBase == Bases[Home]
|
|
then
|
|
score(runnerIndex)
|
|
end
|
|
|
|
local nb = runner.nextBase
|
|
local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y)
|
|
|
|
if distance < 2 then
|
|
runner.nextBase = NextBaseMap[runner.nextBase]
|
|
runner.forcedTo = nil
|
|
return false
|
|
end
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
return prevX ~= runner.x or prevY ~= runner.y
|
|
end
|
|
|
|
---@type number
|
|
local batAngleDeg
|
|
|
|
-- Used for tracking whether or not a pitch was a strike
|
|
local recordedPitchX = nil
|
|
local balls = 0
|
|
local strikes = 0
|
|
|
|
local StrikeZoneStartX <const> = Center.x - 16
|
|
local StrikeZoneEndX <const> = StrikeZoneStartX + 24
|
|
local StrikeZoneStartY <const> = Screen.H - 35
|
|
|
|
function recordStrikePosition()
|
|
if not recordedPitchX and ball.y > StrikeZoneStartY then
|
|
recordedPitchX = ball.x
|
|
end
|
|
end
|
|
|
|
function nextBatter()
|
|
playdate.timer.new(2000, function()
|
|
balls = 0
|
|
strikes = 0
|
|
batter = newRunner()
|
|
end)
|
|
end
|
|
|
|
function walk()
|
|
-- TODO
|
|
nextBatter()
|
|
end
|
|
|
|
function strikeOut()
|
|
-- TODO
|
|
nextBatter()
|
|
end
|
|
|
|
function recordPitch()
|
|
if recordedPitchX > StrikeZoneStartX and recordedPitchX < StrikeZoneEndX then
|
|
strikes = strikes + 1
|
|
if strikes >= 3 then
|
|
strikeOut()
|
|
end
|
|
else
|
|
balls = balls + 1
|
|
if balls >= 4 then
|
|
walk()
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param batDeg number
|
|
function updateBatting(batDeg, batSpeed)
|
|
if ball.y < BallOffscreen then
|
|
ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue()
|
|
ball.size = SmallestBallRadius -- ballFloatAnimator:currentValue()
|
|
end
|
|
|
|
local batAngle = math.rad(batDeg)
|
|
-- 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
|
|
batSpeed >= 0 -- > 0
|
|
and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H)
|
|
and ball.y < 232 --not isTouchingBall(fielders.catcher.x, fielders.catcher.y)
|
|
then
|
|
BatCrackSound:play()
|
|
offenseMode = Offense.running
|
|
local ballAngle = batAngle + math.rad(90)
|
|
|
|
local mult = math.abs(batSpeed / 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, utils.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 = utils.getNearestOf(fielders, ballDestX, ballDestY)
|
|
chasingFielder.target = { x = ballDestX, y = ballDestY }
|
|
end
|
|
end
|
|
|
|
--- Update non-batter runners.
|
|
--- Returns true only if at least one of the given runners moved during this update
|
|
---@param appliedSpeed number
|
|
---@return boolean
|
|
function updateRunning(appliedSpeed)
|
|
ball.size = ballSizeAnimator:currentValue()
|
|
|
|
local runnerMoved = false
|
|
|
|
-- TODO: Filter for the runner closest to the currently-held direction button
|
|
for runnerIndex, runner in ipairs(runners) do
|
|
if runner ~= batter then
|
|
runnerMoved = updateRunner(runner, runnerIndex, appliedSpeed) or runnerMoved
|
|
end
|
|
end
|
|
|
|
return runnerMoved
|
|
end
|
|
|
|
function walkAwayOutRunners()
|
|
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 playerPitch(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)
|
|
end
|
|
end
|
|
|
|
local npcBatDeg = 0
|
|
local BaseNpcBatSpeed <const> = 1500
|
|
local npcBatSpeed = 1500
|
|
|
|
function npcBatAngle()
|
|
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < Center.x + 15) then
|
|
npcBatDeg = npcBatDeg + (deltaSeconds * npcBatSpeed)
|
|
else
|
|
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
|
|
npcBatDeg = 200
|
|
end
|
|
return npcBatDeg
|
|
end
|
|
|
|
function npcBatChange()
|
|
return deltaSeconds * npcBatSpeed
|
|
end
|
|
|
|
function npcRunningSpeed()
|
|
if #runners == 0 then
|
|
return 0
|
|
end
|
|
local touchedBase = isTouchingBase(runners[1].x, runners[1].y)
|
|
if not touchedBase or touchedBase == Bases[Home] then
|
|
return 10
|
|
end
|
|
return 0
|
|
end
|
|
|
|
function updateGameState()
|
|
deltaSeconds = playdate.getElapsedTime() or 0
|
|
playdate.resetElapsedTime()
|
|
local crankChange = 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
|
|
|
|
local playerOnOffense, playerOnDefense = playerIsOn(Sides.offense)
|
|
|
|
if playerOnDefense then
|
|
throwMeter = math.max(0, throwMeter - (deltaSeconds * 150))
|
|
throwMeter = throwMeter + math.abs(crankChange)
|
|
end
|
|
|
|
if offenseMode == Offense.batting then
|
|
recordStrikePosition()
|
|
secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
|
|
|
|
if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then
|
|
recordPitch()
|
|
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
|
|
catcherThrownBall = true
|
|
end
|
|
|
|
local batSpeed
|
|
if playerOnOffense then
|
|
batAngleDeg, batSpeed = (playdate.getCrankPosition() + CrankOffsetDeg) % 360, crankChange
|
|
else
|
|
batAngleDeg, batSpeed = npcBatAngle(), npcBatChange()
|
|
end
|
|
|
|
updateBatting(batAngleDeg, batSpeed)
|
|
|
|
-- TODO: Ensure batter can't be nil, here
|
|
updateRunner(batter, nil, crankChange)
|
|
|
|
if secondsSincePitchAllowed > PitchAfterSeconds then
|
|
if playerOnDefense then
|
|
local throwFly = readThrow()
|
|
if throwFly and not buttonControlledThrow(fielders.pitcher, throwFly, true) then
|
|
playerPitch(throwFly)
|
|
end
|
|
else
|
|
pitch(PitchFlyMs, math.random(#Pitches))
|
|
end
|
|
end
|
|
elseif offenseMode == Offense.running then
|
|
local appliedSpeed = playerOnOffense and crankChange or npcRunningSpeed()
|
|
if updateRunning(appliedSpeed) then
|
|
secondsSinceLastRunnerMove = 0
|
|
else
|
|
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
|
|
if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then
|
|
throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
|
|
resetFielderPositions()
|
|
offenseMode = Offense.batting
|
|
if not batter then
|
|
batter = newRunner()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, fielder in pairs(fielders) do
|
|
updateFielder(fielder)
|
|
end
|
|
walkAwayOutRunners()
|
|
end
|
|
|
|
local MinimapSizeX, MinimapSizeY <const> = Minimap:getSize()
|
|
local MinimapPosX, MinimapPosY = Screen.W - MinimapSizeX, Screen.H - MinimapSizeY
|
|
|
|
local FieldHeight <const> = Bases[Home].y - Bases[Second].y
|
|
|
|
local MinimapMultX <const> = 0.75 * MinimapSizeX / Screen.W
|
|
local MinimapOffsetX <const> = MinimapPosX + 5
|
|
local MinimapMultY <const> = 0.70 * MinimapSizeY / FieldHeight
|
|
local MinimapOffsetY <const> = MinimapPosY - 15
|
|
|
|
function drawMinimap()
|
|
Minimap:draw(MinimapPosX, MinimapPosY)
|
|
gfx.setColor(gfx.kColorBlack)
|
|
for _, runner in pairs(runners) do
|
|
local x = (MinimapMultX * runner.x) + MinimapOffsetX
|
|
local y = (MinimapMultY * runner.y) + MinimapOffsetY
|
|
gfx.fillRect(x, y, 8, 8)
|
|
end
|
|
end
|
|
|
|
---@param fielder Fielder
|
|
---@return boolean isHoldingBall
|
|
function drawFielderGlove(fielder)
|
|
local distanceFromBall =
|
|
utils.distanceBetweenZ(fielder.x, fielder.y, 0, ball.x, ball.y, ballFloatAnimator:currentValue())
|
|
local shoulderX, shoulderY = fielder.x + 10, fielder.y + FielderDanceAnimator:currentValue() + 5
|
|
if distanceFromBall > 20 then
|
|
Glove:draw(shoulderX, shoulderY)
|
|
return false
|
|
else
|
|
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY)
|
|
return true
|
|
end
|
|
end
|
|
|
|
function playdate.update()
|
|
playdate.timer.updateTimers()
|
|
gfx.animation.blinker.updateAll()
|
|
updateGameState()
|
|
|
|
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()
|
|
local ballIsHeld = false
|
|
for _, fielder in pairs(fielders) do
|
|
local fielderY = fielder.y + fielderDanceHeight
|
|
gfx.fillRect(fielder.x, fielderY, 14, 25)
|
|
ballIsHeld = drawFielderGlove(fielder) or ballIsHeld
|
|
end
|
|
|
|
if offenseMode == Offense.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? Scale sprites down as y increases
|
|
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? Change blip speed depending on runner speed?
|
|
PlayerImageBlipper:draw(false, runner.x, runner.y)
|
|
end
|
|
end
|
|
for _, runner in pairs(outRunners) do
|
|
PlayerFrown:draw(runner.x, runner.y)
|
|
end
|
|
|
|
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
|
|
|
|
gfx.setDrawOffset(0, 0)
|
|
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
|
|
drawMinimap()
|
|
end
|
|
drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning)
|
|
drawBallsAndStrikes(300, Screen.H * 0.77, balls, strikes)
|
|
announcer:draw(Center.x, 10)
|
|
end
|
|
|
|
function init()
|
|
playdate.display.setRefreshRate(50)
|
|
gfx.setBackgroundColor(gfx.kColorWhite)
|
|
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
|
|
resetFielderPositions(teams.home.benchPosition)
|
|
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
|
|
|
|
init()
|