Merge branch 'main' of https://git.sagev.space/sage/BatterUp
|
@ -0,0 +1,4 @@
|
||||||
|
std = "lua54+playdate"
|
||||||
|
stds.project = {
|
||||||
|
read_globals = {"playdate"}
|
||||||
|
}
|
4
Makefile
|
@ -1,5 +1,3 @@
|
||||||
SOURCE_FILES := $(shell grep "import '" src/main.lua | grep -v CoreLibs | sed "s/.*'\(.*\)'.*/src\/\1/") src/main.lua
|
|
||||||
|
|
||||||
all:
|
all:
|
||||||
pdc --skip-unknown src BatterUp.pdx
|
pdc --skip-unknown src BatterUp.pdx
|
||||||
|
|
||||||
|
@ -8,7 +6,7 @@ assets:
|
||||||
|
|
||||||
check: assets
|
check: assets
|
||||||
stylua -c --indent-type Spaces src/
|
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/<const>//g' | selene -
|
luacheck -d --codes src/ --exclude-files src/test/
|
||||||
|
|
||||||
test: check
|
test: check
|
||||||
(cd src; find ./test -name '*lua' | xargs -L1 lua)
|
(cd src; find ./test -name '*lua' | xargs -L1 lua)
|
||||||
|
|
|
@ -1,36 +1,26 @@
|
||||||
---@alias ActionResult {}
|
|
||||||
|
|
||||||
---@type table<string, ActionResult>
|
|
||||||
-- selene: allow(unscoped_variables)
|
|
||||||
ActionResult = {
|
|
||||||
Succeeded = {},
|
|
||||||
Failed = {},
|
|
||||||
NeedsMoreTime = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
-- selene: allow(unscoped_variables)
|
|
||||||
actionQueue = {
|
actionQueue = {
|
||||||
---@type ({ action: Action, expireTimeMs: number })[]
|
---@type table<any, { coroutine: thread, expireTimeMs: number }>
|
||||||
queue = {},
|
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.
|
--- Added actions will be called on every runWaiting() update.
|
||||||
--- They will continue to be executed until they return Succeeded or Failed instead of NeedsMoreTime.
|
--- 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.
|
--- 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 maxTimeMs number
|
||||||
---@param action Action
|
---@param action Action
|
||||||
function actionQueue:upsert(name, maxTimeMs, action)
|
function actionQueue:upsert(id, maxTimeMs, action)
|
||||||
if action(0) ~= ActionResult.NeedsMoreTime then
|
if self.queue[id] then
|
||||||
return
|
close(self.queue[id].coroutine)
|
||||||
end
|
end
|
||||||
|
self.queue[id] = {
|
||||||
self.queue[name] = {
|
coroutine = coroutine.create(action),
|
||||||
action = action,
|
|
||||||
expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(),
|
expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
@ -39,10 +29,16 @@ end
|
||||||
--- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired.
|
--- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired.
|
||||||
function actionQueue:runWaiting(deltaSeconds)
|
function actionQueue:runWaiting(deltaSeconds)
|
||||||
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
|
local currentTimeMs = playdate.getCurrentTimeMilliseconds()
|
||||||
for name, actionObject in pairs(self.queue) do
|
|
||||||
local result = actionObject.action(deltaSeconds)
|
for id, actionObject in pairs(self.queue) do
|
||||||
if result ~= ActionResult.NeedsMoreTime or currentTimeMs > actionObject.expireTimeMs then
|
coroutine.resume(actionObject.coroutine, deltaSeconds)
|
||||||
self.queue[name] = nil
|
|
||||||
|
if currentTimeMs > actionObject.expireTimeMs then
|
||||||
|
close(actionObject.coroutine)
|
||||||
|
end
|
||||||
|
|
||||||
|
if coroutine.status(actionObject.coroutine) == "dead" then
|
||||||
|
self.queue[id] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
-- selene: allow(shadowing)
|
|
||||||
local gfx = playdate.graphics
|
local gfx = playdate.graphics
|
||||||
|
|
||||||
local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft")
|
local AnnouncementFont <const> = playdate.graphics.font.new("fonts/Roobert-20-Medium.pft")
|
||||||
|
@ -10,7 +9,6 @@ local AnnouncerAnimatorInY <const> =
|
||||||
local AnnouncerAnimatorOutY <const> =
|
local AnnouncerAnimatorOutY <const> =
|
||||||
playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint)
|
playdate.graphics.animator.new(AnnouncementTransitionMs, 0, -70, playdate.easingFunctions.outQuint)
|
||||||
|
|
||||||
-- selene: allow(unscoped_variables)
|
|
||||||
---@class Announcer
|
---@class Announcer
|
||||||
---@field textQueue string[]
|
---@field textQueue string[]
|
||||||
---@field animatorY pd_animator
|
---@field animatorY pd_animator
|
||||||
|
@ -46,6 +44,7 @@ function Announcer:popIn()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param text string
|
||||||
function Announcer:say(text)
|
function Announcer:say(text)
|
||||||
self.textQueue[#self.textQueue + 1] = text
|
self.textQueue[#self.textQueue + 1] = text
|
||||||
if #self.textQueue == 1 then
|
if #self.textQueue == 1 then
|
||||||
|
|
103
src/assets.lua
|
@ -1,86 +1,67 @@
|
||||||
-- GENERATED FILE - DO NOT EDIT
|
-- GENERATED FILE - DO NOT EDIT
|
||||||
-- Instead, edit the source file directly: assets.lua2p.
|
-- Instead, edit the source file directly: assets.lua2p.
|
||||||
|
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png")
|
DarkPlayerBack = playdate.graphics.image.new("images/game/DarkPlayerBack.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
Glove = playdate.graphics.image.new("images/game/Glove.png")
|
Glove = playdate.graphics.image.new("images/game/Glove.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png")
|
PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
BigBat = playdate.graphics.image.new("images/game/BigBat.png")
|
||||||
|
-- luacheck: ignore
|
||||||
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
|
GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
GameLogo = playdate.graphics.image.new("images/game/GameLogo.png")
|
||||||
|
-- luacheck: ignore
|
||||||
Hat = playdate.graphics.image.new("images/game/Hat.png")
|
Hat = playdate.graphics.image.new("images/game/Hat.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
DarkPlayerBase = playdate.graphics.image.new("images/game/DarkPlayerBase.png")
|
DarkPlayerBase = playdate.graphics.image.new("images/game/DarkPlayerBase.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
MenuImage = playdate.graphics.image.new("images/game/MenuImage.png")
|
MenuImage = playdate.graphics.image.new("images/game/MenuImage.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
PlayerSmile = playdate.graphics.image.new("images/game/PlayerSmile.png")
|
PlayerSmile = playdate.graphics.image.new("images/game/PlayerSmile.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
Minimap = playdate.graphics.image.new("images/game/Minimap.png")
|
Minimap = playdate.graphics.image.new("images/game/Minimap.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png")
|
GrassBackground = playdate.graphics.image.new("images/game/GrassBackground.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
GrassBackgroundSmall = playdate.graphics.image.new("images/game/GrassBackgroundSmall.png")
|
||||||
|
-- luacheck: ignore
|
||||||
LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png")
|
LightPlayerBase = playdate.graphics.image.new("images/game/LightPlayerBase.png")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png")
|
LightPlayerBack = playdate.graphics.image.new("images/game/LightPlayerBack.png")
|
||||||
|
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
|
BatCrackReverb = playdate.sound.sampleplayer.new("sounds/BatCrackReverb.wav")
|
||||||
|
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
|
BootTune = playdate.sound.sampleplayer.new("music/BootTune.wav")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
|
BootTuneOrgany = playdate.sound.sampleplayer.new("music/BootTuneOrgany.wav")
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav")
|
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 = {
|
Logos = {
|
||||||
--selene: allow(unused_variable)
|
{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") },
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
Cats = playdate.graphics.image.new("images/game/logos/Cats.png"),
|
-- luacheck: ignore
|
||||||
--selene: allow(unused_variable)
|
{ name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") },
|
||||||
--selene: allow(unscoped_variables)
|
-- luacheck: ignore
|
||||||
Hearts = playdate.graphics.image.new("images/game/logos/Hearts.png"),
|
{ name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") },
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
{ name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") },
|
||||||
Checkmarks = playdate.graphics.image.new("images/game/logos/Checkmarks.png"),
|
-- luacheck: ignore
|
||||||
--selene: allow(unused_variable)
|
{ name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") },
|
||||||
--selene: allow(unscoped_variables)
|
-- luacheck: ignore
|
||||||
Smiles = playdate.graphics.image.new("images/game/logos/Smiles.png"),
|
{ name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") },
|
||||||
--selene: allow(unused_variable)
|
-- luacheck: ignore
|
||||||
--selene: allow(unscoped_variables)
|
{ name = "Frown", image = playdate.graphics.image.new("images/game/logos/Frown.png") },
|
||||||
FingerGuns = playdate.graphics.image.new("images/game/logos/FingerGuns.png"),
|
-- luacheck: ignore
|
||||||
--selene: allow(unused_variable)
|
{ name = "Arrows", image = playdate.graphics.image.new("images/game/logos/Arrows.png") },
|
||||||
--selene: allow(unscoped_variables)
|
-- luacheck: ignore
|
||||||
Base = playdate.graphics.image.new("images/game/logos/Base.png"),
|
{ name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.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"),
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
!(function dirLookup(dir, extension, newFunc, sep)
|
!(function dirLookup(dir, extension, newFunc, sep, handle)
|
||||||
sep = sep or "\n"
|
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.
|
--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 = ""
|
local assetCode = ""
|
||||||
--Loop through all files
|
--Loop through all files
|
||||||
|
@ -9,9 +12,8 @@
|
||||||
if file:find(extension) then
|
if file:find(extension) then
|
||||||
local varName = file:gsub(".*/(.*)." .. extension, "%1")
|
local varName = file:gsub(".*/(.*)." .. extension, "%1")
|
||||||
file = file:gsub("src/", "")
|
file = file:gsub("src/", "")
|
||||||
assetCode = assetCode .. '--selene: allow(unused_variable)\n'
|
assetCode = assetCode .. '-- luacheck: ignore\n'
|
||||||
assetCode = assetCode .. '--selene: allow(unscoped_variables)\n'
|
assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep
|
||||||
assetCode = assetCode .. varName .. ' = ' .. newFunc .. '("' .. file .. '")' .. sep
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return assetCode
|
return assetCode
|
||||||
|
@ -24,8 +26,10 @@ end)!!(generatedFileWarning())
|
||||||
!!(dirLookup('images/game', 'png', 'playdate.graphics.image.new'))
|
!!(dirLookup('images/game', 'png', 'playdate.graphics.image.new'))
|
||||||
!!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new'))
|
!!(dirLookup('sounds', 'wav', 'playdate.sound.sampleplayer.new'))
|
||||||
!!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new'))
|
!!(dirLookup('music', 'wav', 'playdate.sound.sampleplayer.new'))
|
||||||
--selene: allow(unused_variable)
|
|
||||||
--selene: allow(unscoped_variables)
|
|
||||||
Logos = {
|
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))
|
||||||
}
|
}
|
||||||
|
|
18
src/ball.lua
|
@ -1,16 +1,15 @@
|
||||||
-- selene: allow(unscoped_variables)
|
|
||||||
---@class Ball
|
---@class Ball
|
||||||
---@field x number
|
---@field x number
|
||||||
---@field y number
|
---@field y number
|
||||||
---@field z number
|
---@field z number
|
||||||
---@field size number
|
---@field size number
|
||||||
---@field heldBy Fielder | nil
|
---@field heldBy Fielder | nil
|
||||||
|
---@field catchable boolean
|
||||||
---@field xAnimator SimpleAnimator
|
---@field xAnimator SimpleAnimator
|
||||||
---@field yAnimator SimpleAnimator
|
---@field yAnimator SimpleAnimator
|
||||||
---@field sizeAnimator SimpleAnimator
|
---@field sizeAnimator SimpleAnimator
|
||||||
---@field floatAnimator SimpleAnimator
|
---@field floatAnimator SimpleAnimator
|
||||||
---@field private animatorLib pd_animator_lib
|
---@field private animatorLib pd_animator_lib
|
||||||
---@field private flyTimeMs number
|
|
||||||
Ball = {}
|
Ball = {}
|
||||||
|
|
||||||
---@param animatorLib pd_animator_lib
|
---@param animatorLib pd_animator_lib
|
||||||
|
@ -21,6 +20,7 @@ function Ball.new(animatorLib)
|
||||||
x = C.Center.x --[[@as number]],
|
x = C.Center.x --[[@as number]],
|
||||||
y = C.Center.y --[[@as number]],
|
y = C.Center.y --[[@as number]],
|
||||||
z = 0,
|
z = 0,
|
||||||
|
catchable = true,
|
||||||
size = C.SmallestBallRadius,
|
size = C.SmallestBallRadius,
|
||||||
heldBy = nil --[[@type Runner | nil]],
|
heldBy = nil --[[@type Runner | nil]],
|
||||||
|
|
||||||
|
@ -50,6 +50,13 @@ function Ball:updatePosition()
|
||||||
end
|
end
|
||||||
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.
|
--- Launches the ball from its current position to the given destination.
|
||||||
---@param destX number
|
---@param destX number
|
||||||
---@param destY number
|
---@param destY number
|
||||||
|
@ -58,18 +65,19 @@ end
|
||||||
---@param floaty boolean | nil
|
---@param floaty boolean | nil
|
||||||
---@param customBallScaler pd_animator | nil
|
---@param customBallScaler pd_animator | nil
|
||||||
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
|
function Ball:launch(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
|
||||||
|
throwMeter:reset()
|
||||||
self.heldBy = nil
|
self.heldBy = nil
|
||||||
|
|
||||||
|
-- Prevent silly insta-catches
|
||||||
|
self:markUncatchable()
|
||||||
|
|
||||||
if not flyTimeMs then
|
if not flyTimeMs then
|
||||||
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
|
flyTimeMs = utils.distanceBetween(self.x, self.y, destX, destY) * C.DefaultLaunchPower
|
||||||
end
|
end
|
||||||
|
|
||||||
self.flyTimeMs = flyTimeMs
|
|
||||||
|
|
||||||
if customBallScaler then
|
if customBallScaler then
|
||||||
self.sizeAnimator = customBallScaler
|
self.sizeAnimator = customBallScaler
|
||||||
else
|
else
|
||||||
-- TODO? Scale based on distance?
|
|
||||||
self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
|
self.sizeAnimator = self.animatorLib.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
|
||||||
end
|
end
|
||||||
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
|
self.yAnimator = self.animatorLib.new(flyTimeMs, self.y, destY, easingFunc)
|
||||||
|
|
|
@ -6,18 +6,20 @@
|
||||||
--- forcedTo: Base | nil,
|
--- forcedTo: Base | nil,
|
||||||
--- }
|
--- }
|
||||||
|
|
||||||
-- selene: allow(unscoped_variables)
|
|
||||||
---@class Baserunning
|
---@class Baserunning
|
||||||
---@field runners Runner[]
|
---@field runners Runner[]
|
||||||
---@field outRunners Runner[]
|
---@field outRunners Runner[]
|
||||||
---@field scoredRunners Runner[]
|
---@field scoredRunners Runner[]
|
||||||
---@field batter Runner | nil
|
---@field batter Runner | nil
|
||||||
---@field outs number
|
---@field outs number
|
||||||
|
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
|
||||||
|
---@field secondsSinceLastRunnerMove number
|
||||||
---@field announcer Announcer
|
---@field announcer Announcer
|
||||||
---@field onThirdOut fun()
|
---@field onThirdOut fun()
|
||||||
Baserunning = {}
|
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
|
---@param announcer Announcer
|
||||||
---@return Baserunning
|
---@return Baserunning
|
||||||
|
@ -39,7 +41,7 @@ function Baserunning.new(announcer, onThirdOut)
|
||||||
return o
|
return o
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param runner integer | Runner
|
---@param runner number | Runner
|
||||||
---@param message string | nil
|
---@param message string | nil
|
||||||
---@return boolean wasThirdOut
|
---@return boolean wasThirdOut
|
||||||
function Baserunning:outRunner(runner, message)
|
function Baserunning:outRunner(runner, message)
|
||||||
|
@ -66,7 +68,6 @@ function Baserunning:outRunner(runner, message)
|
||||||
self.onThirdOut()
|
self.onThirdOut()
|
||||||
self.outs = 0
|
self.outs = 0
|
||||||
|
|
||||||
-- TODO: outRunners/scoredRunners split
|
|
||||||
while #self.runners > 0 do
|
while #self.runners > 0 do
|
||||||
self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
|
self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
|
||||||
end
|
end
|
||||||
|
@ -125,16 +126,27 @@ function Baserunning:convertBatterToRunner()
|
||||||
self.batter = nil -- Demote batter to a mere runner
|
self.batter = nil -- Demote batter to a mere runner
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param deltaSeconds number
|
local function walkWayOutRunner(deltaSeconds, runner)
|
||||||
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
|
if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
|
||||||
runner.x = runner.x + (deltaSeconds * 25)
|
runner.x = runner.x + (deltaSeconds * 25)
|
||||||
runner.y = runner.y + (deltaSeconds * 25)
|
runner.y = runner.y + (deltaSeconds * 25)
|
||||||
else
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param deltaSeconds number
|
||||||
|
function Baserunning:walkAwayOutRunners(deltaSeconds)
|
||||||
|
for i, runner in ipairs(self.outRunners) do
|
||||||
|
if not walkWayOutRunner(deltaSeconds, runner) then
|
||||||
table.remove(self.outRunners, i)
|
table.remove(self.outRunners, i)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
for i, runner in ipairs(self.scoredRunners) do
|
||||||
|
if not walkWayOutRunner(deltaSeconds, runner) then
|
||||||
|
table.remove(self.scoredRunners, i)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return Runner
|
---@return Runner
|
||||||
|
@ -153,16 +165,16 @@ function Baserunning:pushNewBatter()
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param self table
|
---@param self table
|
||||||
---@param runnerIndex integer
|
---@param runnerIndex number
|
||||||
function Baserunning:runnerScored(runnerIndex)
|
function Baserunning:runnerScored(runnerIndex)
|
||||||
-- TODO: outRunners/scoredRunners split
|
-- 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)
|
table.remove(self.runners, runnerIndex)
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Returns true only if the given runner moved during this update.
|
--- Returns true only if the given runner moved during this update.
|
||||||
---@param runner Runner | nil
|
---@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 appliedSpeed number
|
||||||
---@param deltaSeconds number
|
---@param deltaSeconds number
|
||||||
---@return boolean runnerMoved, boolean runnerScored
|
---@return boolean runnerMoved, boolean runnerScored
|
||||||
|
@ -227,9 +239,9 @@ end
|
||||||
--- Update non-batter runners.
|
--- Update non-batter runners.
|
||||||
--- Returns true only if at least one of the given runners moved during this update
|
--- Returns true only if at least one of the given runners moved during this update
|
||||||
---@param appliedSpeed number | fun(runner: Runner): number
|
---@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)
|
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
|
||||||
local someRunnerMoved = false
|
local runnersStillMoving = false
|
||||||
local runnersScored = 0
|
local runnersScored = 0
|
||||||
|
|
||||||
local speedIsFunction = type(appliedSpeed) == "function"
|
local speedIsFunction = type(appliedSpeed) == "function"
|
||||||
|
@ -241,20 +253,24 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon
|
||||||
speed = appliedSpeed(runner)
|
speed = appliedSpeed(runner)
|
||||||
end
|
end
|
||||||
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
|
local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
|
||||||
someRunnerMoved = someRunnerMoved or thisRunnerMoved
|
runnersStillMoving = runnersStillMoving or thisRunnerMoved
|
||||||
if thisRunnerScored then
|
if thisRunnerScored then
|
||||||
runnersScored = runnersScored + 1
|
runnersScored = runnersScored + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if someRunnerMoved then
|
if runnersStillMoving then
|
||||||
|
self.secondsSinceLastRunnerMove = 0
|
||||||
self:updateForcedRunners()
|
self:updateForcedRunners()
|
||||||
|
else
|
||||||
|
self.secondsSinceLastRunnerMove = (self.secondsSinceLastRunnerMove or 0) + deltaSeconds
|
||||||
end
|
end
|
||||||
|
|
||||||
return someRunnerMoved, runnersScored
|
return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- luacheck: ignore
|
||||||
if not playdate or playdate.TEST_MODE then
|
if not playdate or playdate.TEST_MODE then
|
||||||
return Baserunning
|
return Baserunning
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
-- selene: allow(unscoped_variables)
|
|
||||||
C = {}
|
C = {}
|
||||||
|
|
||||||
C.Screen = {
|
C.Screen = {
|
||||||
|
@ -31,7 +30,7 @@ C.FieldHeight = C.Bases[C.Home].y - C.Bases[C.Second].y
|
||||||
-- Pseudo-base for batter to target
|
-- Pseudo-base for batter to target
|
||||||
C.RightHandedBattersBox = {
|
C.RightHandedBattersBox = {
|
||||||
x = C.Bases[C.Home].x - 30,
|
x = C.Bases[C.Home].x - 30,
|
||||||
y = C.Bases[C.Home].y,
|
y = C.Bases[C.Home].y + 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
---@type table<Base, Base | nil>
|
---@type table<Base, Base | nil>
|
||||||
|
@ -71,8 +70,8 @@ C.BallOffscreen = 999
|
||||||
C.PitchAfterSeconds = 6
|
C.PitchAfterSeconds = 6
|
||||||
C.ReturnToPitcherAfterSeconds = 2.4
|
C.ReturnToPitcherAfterSeconds = 2.4
|
||||||
C.PitchFlyMs = 1050
|
C.PitchFlyMs = 1050
|
||||||
C.PitchStartX = 195
|
C.PitchStart = utils.xy(195, 105)
|
||||||
C.PitchStartY, C.PitchEndY = 105, 240
|
C.PitchEndY = 240
|
||||||
|
|
||||||
C.DefaultLaunchPower = 4
|
C.DefaultLaunchPower = 4
|
||||||
|
|
||||||
|
@ -92,33 +91,23 @@ C.SmallestBallRadius = 6
|
||||||
|
|
||||||
C.BatLength = 35
|
C.BatLength = 35
|
||||||
|
|
||||||
-- TODO: enums implemented this way are probably going to be difficult to serialize!
|
---@alias OffenseState "batting" | "running" | "walking" | "homeRun"
|
||||||
|
|
||||||
---@alias OffenseState table
|
|
||||||
--- An enum for what state the offense is in
|
--- An enum for what state the offense is in
|
||||||
---@type table<string, OffenseState>
|
---@type table<string, OffenseState>
|
||||||
C.Offense = {
|
C.Offense = {
|
||||||
batting = {},
|
batting = "batting",
|
||||||
running = {},
|
running = "running",
|
||||||
walking = {},
|
walking = "walking",
|
||||||
|
homeRun = "homeRun",
|
||||||
}
|
}
|
||||||
|
|
||||||
---@alias Side table
|
---@alias Side "offense" | "defense"
|
||||||
--- An enum for which side (offense or defense) a team is on.
|
|
||||||
---@type table<string, Side>
|
|
||||||
C.Sides = {
|
|
||||||
offense = {},
|
|
||||||
defense = {},
|
|
||||||
}
|
|
||||||
|
|
||||||
C.PitcherStartPos = {
|
C.PitcherStartPos = {
|
||||||
x = C.Screen.W * 0.48,
|
x = C.Screen.W * 0.48,
|
||||||
y = C.Screen.H * 0.40,
|
y = C.Screen.H * 0.40,
|
||||||
}
|
}
|
||||||
|
|
||||||
C.ThrowMeterMax = 10
|
|
||||||
C.ThrowMeterDrainPerSec = 150
|
|
||||||
|
|
||||||
--- Controls how hard the ball can be hit, and
|
--- Controls how hard the ball can be hit, and
|
||||||
--- how fast the ball can be thrown.
|
--- how fast the ball can be thrown.
|
||||||
C.CrankPower = 10
|
C.CrankPower = 10
|
||||||
|
@ -132,6 +121,24 @@ C.WalkedRunnerSpeed = 10
|
||||||
|
|
||||||
C.ResetFieldersAfterSeconds = 2.5
|
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
|
if not playdate then
|
||||||
return C
|
return C
|
||||||
end
|
end
|
||||||
|
|
73
src/dbg.lua
|
@ -1,7 +1,5 @@
|
||||||
-- selene: allow(unscoped_variables)
|
|
||||||
dbg = {}
|
dbg = {}
|
||||||
|
|
||||||
-- selene: allow(unused_variable)
|
|
||||||
function dbg.label(value, name)
|
function dbg.label(value, name)
|
||||||
if type(value) == "table" then
|
if type(value) == "table" then
|
||||||
print(name .. ":")
|
print(name .. ":")
|
||||||
|
@ -19,7 +17,6 @@ function dbg.label(value, name)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Only works if called with the bases empty (i.e. the only runner should be the batter.
|
-- 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)
|
function dbg.loadTheBases(br)
|
||||||
br:pushNewBatter()
|
br:pushNewBatter()
|
||||||
br:pushNewBatter()
|
br:pushNewBatter()
|
||||||
|
@ -38,6 +35,76 @@ function dbg.loadTheBases(br)
|
||||||
br.runners[4].nextBase = C.Bases[C.Home]
|
br.runners[4].nextBase = C.Bases[C.Home]
|
||||||
end
|
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
|
if not playdate then
|
||||||
return dbg
|
return dbg
|
||||||
end
|
end
|
||||||
|
|
|
@ -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<TeamId, TeamInningData>)[]
|
||||||
|
|
||||||
|
Statistics = {}
|
||||||
|
|
||||||
|
local function newTeamInning()
|
||||||
|
return {
|
||||||
|
score = 0,
|
||||||
|
pitching = {
|
||||||
|
balls = 0,
|
||||||
|
strikes = 0,
|
||||||
|
},
|
||||||
|
hits = {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return table<TeamId, TeamInningData>
|
||||||
|
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 <const> = 70
|
||||||
|
|
||||||
|
local SmallFont <const> = playdate.graphics.font.new("fonts/font-full-circle.pft")
|
||||||
|
local ScoreFont <const> = playdate.graphics.font.new("fonts/Asheville-Sans-14-Bold.pft")
|
||||||
|
local NumWidth <const> = ScoreFont:getTextWidth("0")
|
||||||
|
local NumHeight <const> = ScoreFont:getHeight()
|
||||||
|
local AwayWidth <const> = ScoreFont:getTextWidth("AWAY")
|
||||||
|
local InningMargin = 4
|
||||||
|
|
||||||
|
local InningDrawWidth <const> = (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 <const> = -4 + (NumHeight * 2) + MarginY
|
||||||
|
local AwayY <const> = -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
|
|
@ -5,14 +5,14 @@ local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2
|
||||||
---@param fielderX number
|
---@param fielderX number
|
||||||
---@param fielderY number
|
---@param fielderY number
|
||||||
---@return boolean isHoldingBall
|
---@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 distanceFromBall = utils.distanceBetweenZ(fielderX, fielderY, 0, ball.x, ball.y, ball.z)
|
||||||
local shoulderX, shoulderY = fielderX + 10, fielderY - 5
|
local shoulderX, shoulderY = fielderX + 10, fielderY - 5
|
||||||
if distanceFromBall > 20 then
|
if distanceFromBall > 20 then
|
||||||
Glove:draw(shoulderX, shoulderY)
|
Glove:draw(shoulderX, shoulderY, flip)
|
||||||
return false
|
return false
|
||||||
else
|
else
|
||||||
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY)
|
GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -22,7 +22,7 @@ end
|
||||||
---@param x number
|
---@param x number
|
||||||
---@param y number
|
---@param y number
|
||||||
---@return boolean isHoldingBall
|
---@return boolean isHoldingBall
|
||||||
function drawFielder(playerSprites, ball, x, y)
|
function drawFielder(playerSprites, ball, x, y, flip)
|
||||||
playerSprites.smiling:draw(x, y - 20)
|
playerSprites.smiling:draw(x, y - 20, flip)
|
||||||
return drawFielderGlove(ball, x, y)
|
return drawFielderGlove(ball, x, y)
|
||||||
end
|
end
|
||||||
|
|
|
@ -83,11 +83,10 @@ local ScoreboardHeight <const> = 55
|
||||||
local Indicator = "> "
|
local Indicator = "> "
|
||||||
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
|
local IndicatorWidth <const> = ScoreFont:getTextWidth(Indicator)
|
||||||
|
|
||||||
---@param teams any
|
|
||||||
---@param battingTeam any
|
---@param battingTeam any
|
||||||
---@return string, number, string, number
|
---@return string, number, string, number
|
||||||
function getIndicators(teams, battingTeam)
|
function getIndicators(battingTeam)
|
||||||
if teams.home == battingTeam then
|
if battingTeam == "home" then
|
||||||
return Indicator, 0, "", IndicatorWidth
|
return Indicator, 0, "", IndicatorWidth
|
||||||
end
|
end
|
||||||
return "", IndicatorWidth, Indicator, 0
|
return "", IndicatorWidth, Indicator, 0
|
||||||
|
@ -101,11 +100,11 @@ local stats = {
|
||||||
battingTeam = nil,
|
battingTeam = nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawScoreboardImpl(x, y, teams)
|
function drawScoreboardImpl(x, y)
|
||||||
local homeScore = stats.homeScore
|
local homeScore = stats.homeScore
|
||||||
local awayScore = stats.awayScore
|
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 homeScoreText = homeIndicator .. "HOME " .. (homeScore > 9 and homeScore or " " .. homeScore)
|
||||||
local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
|
local awayScoreText = awayIndicator .. "AWAY " .. (awayScore > 9 and awayScore or " " .. awayScore)
|
||||||
|
@ -146,17 +145,17 @@ end
|
||||||
|
|
||||||
local newStats = stats
|
local newStats = stats
|
||||||
|
|
||||||
function drawScoreboard(x, y, teams, outs, battingTeam, inning)
|
function drawScoreboard(x, y, homeScore, awayScore, outs, battingTeam, inning)
|
||||||
if
|
if
|
||||||
newStats.homeScore ~= teams.home.score
|
newStats.homeScore ~= homeScore
|
||||||
or newStats.awayScore ~= teams.away.score
|
or newStats.awayScore ~= awayScore
|
||||||
or newStats.outs ~= outs
|
or newStats.outs ~= outs
|
||||||
or newStats.inning ~= inning
|
or newStats.inning ~= inning
|
||||||
or newStats.battingTeam ~= battingTeam
|
or newStats.battingTeam ~= battingTeam
|
||||||
then
|
then
|
||||||
newStats = {
|
newStats = {
|
||||||
homeScore = teams.home.score,
|
homeScore = homeScore,
|
||||||
awayScore = teams.away.score,
|
awayScore = awayScore,
|
||||||
outs = outs,
|
outs = outs,
|
||||||
inning = inning,
|
inning = inning,
|
||||||
battingTeam = battingTeam,
|
battingTeam = battingTeam,
|
||||||
|
@ -165,5 +164,5 @@ function drawScoreboard(x, y, teams, outs, battingTeam, inning)
|
||||||
stats = newStats
|
stats = newStats
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
drawScoreboardImpl(x, y, teams)
|
drawScoreboardImpl(x, y)
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -15,6 +15,7 @@ function maybeDrawInverted(image, x, y, drawInverted)
|
||||||
gfx.setImageDrawMode(drawMode)
|
gfx.setImageDrawMode(drawMode)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- TODO: Custom names on jerseys?
|
||||||
---@return SpriteCollection
|
---@return SpriteCollection
|
||||||
---@param base pd_image
|
---@param base pd_image
|
||||||
---@param isDark boolean
|
---@param isDark boolean
|
||||||
|
@ -52,7 +53,20 @@ function buildCollection(base, back, logo, isDark)
|
||||||
end
|
end
|
||||||
|
|
||||||
--selene: allow(unscoped_variables)
|
--selene: allow(unscoped_variables)
|
||||||
AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, Logos.Base, true)
|
---@type SpriteCollection
|
||||||
|
AwayTeamSprites = nil
|
||||||
|
|
||||||
--selene: allow(unscoped_variables)
|
--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)
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
---@type pd_graphics_lib
|
||||||
|
local gfx <const> = playdate.graphics
|
||||||
|
|
||||||
|
local ThrowMeterHeight <const> = 50
|
||||||
|
local ThrowMeterLingerSec <const> = 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
|
|
@ -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
|
109
src/fielding.lua
|
@ -1,19 +1,26 @@
|
||||||
--- @class Fielder {
|
--- @class Fielder {
|
||||||
--- @field catchEligible boolean
|
|
||||||
--- @field x number
|
--- @field x number
|
||||||
--- @field y number
|
--- @field y number
|
||||||
--- @field target XyPair | nil
|
--- @field target XyPair | nil
|
||||||
--- @field speed number
|
--- @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
|
---@class Fielding
|
||||||
---@field fielders table<string, Fielder>
|
---@field fielders Fielders
|
||||||
---@field fielderHoldingBall Fielder | nil
|
---@field fielderHoldingBall Fielder | nil
|
||||||
Fielding = {}
|
Fielding = {}
|
||||||
|
|
||||||
local FielderDanceAnimator <const> = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
|
FielderDanceAnimator = playdate.graphics.animator.new(1, 10, 0, utils.easingHill)
|
||||||
FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1
|
FielderDanceAnimator.repeatCount = C.DanceBounceCount - 1
|
||||||
|
|
||||||
---@param name string
|
---@param name string
|
||||||
|
@ -23,7 +30,6 @@ local function newFielder(name, speed)
|
||||||
return {
|
return {
|
||||||
name = name,
|
name = name,
|
||||||
speed = speed * C.FielderRunMult,
|
speed = speed * C.FielderRunMult,
|
||||||
catchEligible = true,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -79,45 +85,54 @@ end
|
||||||
|
|
||||||
---@param deltaSeconds number
|
---@param deltaSeconds number
|
||||||
---@param fielder Fielder
|
---@param fielder Fielder
|
||||||
---@param ballPos XyPair
|
---@param ball Ball
|
||||||
---@return boolean inCatchingRange
|
---@return boolean canCatch
|
||||||
local function updateFielderPosition(deltaSeconds, fielder, ballPos)
|
local function updateFielderPosition(deltaSeconds, fielder, ball)
|
||||||
if fielder.target ~= nil then
|
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
|
fielder.target = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return utils.distanceBetweenPoints(fielder, ballPos) < C.BallCatchHitbox
|
return ball.catchable and utils.distanceBetweenPoints(fielder, ball) < C.BallCatchHitbox
|
||||||
end
|
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.
|
--- Selects the nearest fielder to move toward the given coordinates.
|
||||||
--- Other fielders should attempt to cover their bases
|
--- Other fielders should attempt to cover their bases
|
||||||
---@param self table
|
|
||||||
---@param ballDestX number
|
---@param ballDestX number
|
||||||
---@param ballDestY number
|
---@param ballDestY number
|
||||||
function Fielding:haveSomeoneChase(ballDestX, ballDestY)
|
function Fielding:haveSomeoneChase(ballDestX, ballDestY)
|
||||||
local chasingFielder = utils.getNearestOf(self.fielders, 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
|
for _, base in ipairs(C.Bases) do
|
||||||
local nearest = utils.getNearestOf(self.fielders, base.x, base.y, function(fielder)
|
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
|
-- Skip the pitcher for 2B - they're considered closer than second or shortstop.
|
||||||
return fielder ~= chasingFielder and fielder ~= self.fielders.pitcher
|
if fielder == self.fielders.pitcher and base == C.Bases[C.Second] then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return fielder ~= chasingFielder
|
||||||
end)
|
end)
|
||||||
nearest.target = base
|
nearest.target = base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- **Also updates `ball.heldby`**
|
||||||
---@param ball Ball
|
---@param ball Ball
|
||||||
---@param deltaSeconds number
|
---@param deltaSeconds number
|
||||||
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
|
---@return Fielder | nil fielderHoldingBall nil if no fielder is currently touching the ball
|
||||||
function Fielding:updateFielderPositions(ball, deltaSeconds)
|
function Fielding:updateFielderPositions(ball, deltaSeconds)
|
||||||
local fielderHoldingBall = nil
|
local fielderHoldingBall
|
||||||
for _, fielder in pairs(self.fielders) do
|
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
|
fielderHoldingBall = fielder
|
||||||
ball.heldBy = fielder -- How much havoc will this wreak?
|
ball.heldBy = fielder -- How much havoc will this wreak?
|
||||||
end
|
end
|
||||||
|
@ -129,73 +144,45 @@ function Fielding:updateFielderPositions(ball, deltaSeconds)
|
||||||
return fielderHoldingBall
|
return fielderHoldingBall
|
||||||
end
|
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?
|
-- TODO? Start moving target fielders close sooner?
|
||||||
---@param field Fielding
|
---@param field Fielding
|
||||||
---@param targetBase Base
|
---@param targetBase Base
|
||||||
---@param launchBall LaunchBall
|
---@param ball { launch: LaunchBall }
|
||||||
---@param throwFlyMs number
|
---@param throwFlyMs number
|
||||||
---@return ActionResult
|
local function userThrowToCoroutine(field, targetBase, ball, throwFlyMs)
|
||||||
local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs)
|
while true do
|
||||||
if field.fielderHoldingBall == nil then
|
if field.fielderHoldingBall == nil then
|
||||||
return ActionResult.NeedsMoreTime
|
coroutine.yield()
|
||||||
end
|
else
|
||||||
local closestFielder = utils.getNearestOf(field.fielders, targetBase.x, targetBase.y, function(fielder)
|
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
|
return fielder ~= field.fielderHoldingBall -- presumably, this is who will be doing the throwing
|
||||||
end)
|
end)
|
||||||
|
|
||||||
closestFielder.target = targetBase
|
closestFielder.target = targetBase
|
||||||
launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
|
ball:launch(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
|
||||||
Fielding.markIneligible(field.fielderHoldingBall)
|
|
||||||
return ActionResult.Succeeded
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Buffer in a fielder throw action.
|
--- Buffer in a fielder throw action.
|
||||||
---@param self table
|
---@param self table
|
||||||
---@param targetBase Base
|
---@param targetBase Base
|
||||||
---@param launchBall LaunchBall
|
---@param ball { launch: LaunchBall }
|
||||||
---@param throwFlyMs number
|
---@param throwFlyMs number
|
||||||
function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
|
function Fielding:userThrowTo(targetBase, ball, throwFlyMs)
|
||||||
local maxTryTimeMs = 5000
|
local maxTryTimeMs = 5000
|
||||||
actionQueue:upsert("userThrowTo", maxTryTimeMs, function()
|
actionQueue:upsert("userThrowTo", maxTryTimeMs, function()
|
||||||
return userThrowToImpl(self, targetBase, launchBall, throwFlyMs)
|
userThrowToCoroutine(self, targetBase, ball, throwFlyMs)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Fielding:celebrate()
|
function Fielding.celebrate()
|
||||||
FielderDanceAnimator:reset(C.DanceBounceMs)
|
FielderDanceAnimator:reset(C.DanceBounceMs)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param fielderSprites SpriteCollection
|
-- luacheck: ignore
|
||||||
---@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
|
|
||||||
|
|
||||||
if not playdate or playdate.TEST_MODE then
|
if not playdate or playdate.TEST_MODE then
|
||||||
return { Fielding, newFielder }
|
return { Fielding, newFielder }
|
||||||
end
|
end
|
||||||
|
|
After Width: | Height: | Size: 10 KiB |
|
@ -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
|
||||||
|
<EFBFBD> 13
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.4 KiB |
|
@ -7,7 +7,8 @@ function getDrawOffset(ballX, ballY)
|
||||||
if ballY > C.Screen.H or ballX >= C.BallOffscreen then
|
if ballY > C.Screen.H or ballX >= C.BallOffscreen then
|
||||||
return 0, 0
|
return 0, 0
|
||||||
end
|
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
|
if ballX > 0 and ballX < C.Screen.W then
|
||||||
offsetX = 0
|
offsetX = 0
|
||||||
|
@ -17,10 +18,11 @@ function getDrawOffset(ballX, ballY)
|
||||||
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
|
offsetX = math.min(C.Screen.W * 2, (ballX * -1) + C.Screen.W)
|
||||||
end
|
end
|
||||||
|
|
||||||
return offsetX * 1.3, offsetY * 1.5
|
return offsetX * 1.3, offsetY
|
||||||
end
|
end
|
||||||
|
|
||||||
-- selene: allow(unscoped_variables)
|
---@class Blipper
|
||||||
|
---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number)
|
||||||
blipper = {}
|
blipper = {}
|
||||||
|
|
||||||
--- Build an object that simply "blips" between the given images at the given interval.
|
--- 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,
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
|
|
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 43 KiB |
After Width: | Height: | Size: 7.0 KiB |
After Width: | Height: | Size: 10 KiB |
|
@ -0,0 +1,121 @@
|
||||||
|
---@class MainMenu
|
||||||
|
MainMenu = {
|
||||||
|
---@type { new: fun(settings: Settings): { update: fun(self) } }
|
||||||
|
next = nil,
|
||||||
|
}
|
||||||
|
local gfx = playdate.graphics
|
||||||
|
|
||||||
|
local StartFont <const> = 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
|
719
src/main.lua
|
@ -8,9 +8,12 @@ import 'CoreLibs/timer.lua'
|
||||||
import 'CoreLibs/ui.lua'
|
import 'CoreLibs/ui.lua'
|
||||||
-- stylua: ignore end
|
-- stylua: ignore end
|
||||||
|
|
||||||
|
--- @alias Scene { update: fun(self: self) }
|
||||||
|
|
||||||
--- @alias EasingFunc fun(number, number, number, number): number
|
--- @alias EasingFunc fun(number, number, number, number): number
|
||||||
|
|
||||||
--- @alias LaunchBall fun(
|
--- @alias LaunchBall fun(
|
||||||
|
--- self: self,
|
||||||
--- destX: number,
|
--- destX: number,
|
||||||
--- destY: number,
|
--- destY: number,
|
||||||
--- easingFunc: EasingFunc,
|
--- easingFunc: EasingFunc,
|
||||||
|
@ -23,9 +26,7 @@ import 'CoreLibs/ui.lua'
|
||||||
import 'utils.lua'
|
import 'utils.lua'
|
||||||
import 'constants.lua'
|
import 'constants.lua'
|
||||||
import 'assets.lua'
|
import 'assets.lua'
|
||||||
import 'draw/player.lua'
|
import 'main-menu.lua'
|
||||||
import 'draw/overlay.lua'
|
|
||||||
import 'draw/fielder.lua'
|
|
||||||
|
|
||||||
import 'action-queue.lua'
|
import 'action-queue.lua'
|
||||||
import 'announcer.lua'
|
import 'announcer.lua'
|
||||||
|
@ -35,190 +36,268 @@ import 'dbg.lua'
|
||||||
import 'fielding.lua'
|
import 'fielding.lua'
|
||||||
import 'graphics.lua'
|
import 'graphics.lua'
|
||||||
import 'npc.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
|
-- stylua: ignore end
|
||||||
|
|
||||||
-- selene: allow(shadowing)
|
-- TODO: Customizable field structure. E.g. stands and ads etc.
|
||||||
|
|
||||||
local gfx <const>, C <const> = playdate.graphics, C
|
local gfx <const>, C <const> = playdate.graphics, C
|
||||||
|
|
||||||
local announcer = Announcer.new()
|
---@alias Team { benchPosition: XyPair }
|
||||||
local fielding = Fielding.new()
|
---@type table<TeamId, Team>
|
||||||
-- 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 <const> = utils.xy(C.Center.x - 34, 215)
|
|
||||||
local batTip <const> = utils.xy(0, 0)
|
|
||||||
local batAngleDeg = C.CrankOffsetDeg
|
|
||||||
|
|
||||||
local catcherThrownBall = false
|
|
||||||
|
|
||||||
---@alias Team { score: number, benchPosition: XyPair }
|
|
||||||
---@type table<string, Team>
|
|
||||||
local teams <const> = {
|
local teams <const> = {
|
||||||
home = {
|
home = {
|
||||||
score = 0,
|
|
||||||
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
|
benchPosition = utils.xy(C.Screen.W + 10, C.Center.y),
|
||||||
},
|
},
|
||||||
away = {
|
away = {
|
||||||
score = 0,
|
|
||||||
benchPosition = utils.xy(-10, C.Center.y),
|
benchPosition = utils.xy(-10, C.Center.y),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
local inning = 1
|
---@alias TeamId 'home' | 'away'
|
||||||
|
|
||||||
local battingTeam = teams.away
|
--- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
|
||||||
local offenseState = C.Offense.batting
|
---@class Settings
|
||||||
|
---@field finalInning number
|
||||||
-- TODO: Replace with timers, repeatedly reset, instead of constantly setting to 0
|
---@field userTeam TeamId | nil
|
||||||
local secondsSinceLastRunnerMove = 0
|
---@field awayTeamSprites SpriteCollection
|
||||||
local secondsSincePitchAllowed = 0
|
---@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,
|
-- 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.
|
-- but they need to be kept in sync with the rest of the globals.
|
||||||
local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper
|
---@field runnerBlipper Blipper
|
||||||
local battingTeamSprites = AwayTeamSprites
|
---@field battingTeamSprites SpriteCollection
|
||||||
local fieldingTeamSprites = HomeTeamSprites
|
---@field fieldingTeamSprites SpriteCollection
|
||||||
|
|
||||||
-------------------------
|
---@class Game
|
||||||
-- END OF GLOBAL STATE --
|
---@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 <const> = 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) }
|
local homeTeamBlipper = blipper.new(100, settings.homeTeamSprites.smiling, settings.homeTeamSprites.lowHat)
|
||||||
---@alias Pitch { x: SimpleAnimator, y: SimpleAnimator, z: SimpleAnimator | nil }
|
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 o = setmetatable({
|
||||||
local Pitches <const> = {
|
settings = settings,
|
||||||
-- Fastball
|
announcer = announcer,
|
||||||
{
|
fielding = fielding,
|
||||||
x = gfx.animator.new(0, C.PitchStartX, C.PitchStartX, playdate.easingFunctions.linear),
|
homeTeamBlipper = homeTeamBlipper,
|
||||||
y = gfx.animator.new(C.PitchFlyMs / 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
|
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(),
|
||||||
},
|
},
|
||||||
-- Curve ball
|
}, { __index = Game })
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
y = gfx.animator.new(C.PitchFlyMs * 1.3, C.PitchStartY, C.PitchEndY, playdate.easingFunctions.linear),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
---@return boolean userIsOnSide, boolean userIsOnOtherSide
|
||||||
local function userIsOn(side)
|
function Game:userIsOn(side)
|
||||||
if UserTeam == nil then
|
if self.settings.userTeam == nil then
|
||||||
-- Both teams are NPC-driven
|
-- Both teams are NPC-driven
|
||||||
return false, false
|
return false, false
|
||||||
end
|
end
|
||||||
local ret
|
local ret
|
||||||
if UserTeam == battingTeam then
|
if self.settings.userTeam == self.state.battingTeam then
|
||||||
ret = side == C.Sides.offense
|
ret = side == "offense"
|
||||||
else
|
else
|
||||||
ret = side == C.Sides.defense
|
ret = side == "defense"
|
||||||
end
|
end
|
||||||
return ret, not ret
|
return ret, not ret
|
||||||
end
|
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 pitchFlyTimeMs number | nil
|
||||||
---@param pitchTypeIndex number | nil
|
---@param pitchTypeIndex number | nil
|
||||||
local function pitch(pitchFlyTimeMs, pitchTypeIndex)
|
---@param accuracy number The closer to 1.0, the better
|
||||||
Fielding.markIneligible(fielding.fielders.pitcher)
|
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy)
|
||||||
ball.heldBy = nil
|
self.state.ball:markUncatchable()
|
||||||
catcherThrownBall = false
|
self.state.ball.heldBy = nil
|
||||||
offenseState = C.Offense.batting
|
self.state.pitchIsOver = false
|
||||||
|
self.state.offenseState = C.Offense.batting
|
||||||
|
|
||||||
local current = Pitches[pitchTypeIndex]
|
local current = Pitches[pitchTypeIndex](accuracy, self.state.ball)
|
||||||
ball.xAnimator = current.x
|
self.state.ball.xAnimator = current.x
|
||||||
ball.yAnimator = current.y or Pitches[1].y
|
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
|
-- if current.z then
|
||||||
-- ball.floatAnimator = current.z
|
-- ball.floatAnimator = current.z
|
||||||
-- ball.floatAnimator:reset()
|
-- ball.floatAnimator:reset()
|
||||||
-- end
|
-- end
|
||||||
|
|
||||||
if pitchFlyTimeMs then
|
if pitchFlyTimeMs then
|
||||||
ball.xAnimator:reset(pitchFlyTimeMs)
|
self.state.ball.xAnimator:reset(pitchFlyTimeMs)
|
||||||
ball.yAnimator:reset(pitchFlyTimeMs)
|
self.state.ball.yAnimator:reset(pitchFlyTimeMs)
|
||||||
else
|
else
|
||||||
ball.xAnimator:reset()
|
self.state.ball.xAnimator:reset()
|
||||||
ball.yAnimator:reset()
|
self.state.ball.yAnimator:reset()
|
||||||
end
|
end
|
||||||
|
|
||||||
secondsSincePitchAllowed = 0
|
pitchTracker.secondsSinceLastPitch = 0
|
||||||
end
|
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()
|
pitchTracker:reset()
|
||||||
local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home
|
local homeScore, awayScore = utils.totalScores(self.state.stats)
|
||||||
local gameOver = inning == 9 and teams.away.score ~= teams.home.score
|
local isFinalInning = self.state.inning >= self.settings.finalInning
|
||||||
if not gameOver then
|
local gameOver = isFinalInning and self.state.battingTeam == "home" and awayScore ~= homeScore
|
||||||
fielding:celebrate()
|
gameOver = gameOver or self.state.battingTeam == "away" and isFinalInning and homeScore > awayScore
|
||||||
secondsSinceLastRunnerMove = -7
|
Fielding.celebrate()
|
||||||
fielding:benchTo(currentlyFieldingTeam.benchPosition)
|
|
||||||
announcer:say("SWITCHING SIDES...")
|
|
||||||
end
|
|
||||||
|
|
||||||
if gameOver then
|
if gameOver then
|
||||||
announcer:say("AND THAT'S THE BALL GAME!")
|
self.announcer:say("THAT'S THE BALL GAME!")
|
||||||
else
|
playdate.timer.new(3000, function()
|
||||||
fielding:resetFielderPositions()
|
transitionTo(BoxScore.new(self.state.stats))
|
||||||
if battingTeam == teams.home then
|
end)
|
||||||
inning = inning + 1
|
return
|
||||||
end
|
end
|
||||||
battingTeam = currentlyFieldingTeam
|
|
||||||
|
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()
|
playdate.timer.new(2000, function()
|
||||||
if battingTeam == teams.home then
|
if self.state.battingTeam == "home" then
|
||||||
battingTeamSprites = HomeTeamSprites
|
self.state.battingTeamSprites = self.settings.homeTeamSprites
|
||||||
runnerBlipper = HomeTeamBlipper
|
self.state.runnerBlipper = self.homeTeamBlipper
|
||||||
fieldingTeamSprites = AwayTeamSprites
|
self.state.fieldingTeamSprites = self.settings.awayTeamSprites
|
||||||
else
|
else
|
||||||
battingTeamSprites = AwayTeamSprites
|
self.state.battingTeamSprites = self.settings.awayTeamSprites
|
||||||
fieldingTeamSprites = HomeTeamSprites
|
self.state.fieldingTeamSprites = self.settings.homeTeamSprites
|
||||||
runnerBlipper = AwayTeamBlipper
|
self.state.runnerBlipper = self.awayTeamBlipper
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local baserunning = Baserunning.new(announcer, nextHalfInning)
|
---@return TeamInningData
|
||||||
local npc = Npc.new(baserunning.runners, fielding.fielders)
|
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
|
---@param scoredRunCount number
|
||||||
local function score(scoredRunCount)
|
function Game:score(scoredRunCount)
|
||||||
battingTeam.score = battingTeam.score + scoredRunCount
|
-- TODO: end the game when it's the bottom of the ninth the home team is now in the lead.
|
||||||
announcer:say("SCORE!")
|
-- outRunners/scoredRunners split
|
||||||
|
self:battingTeamCurrentInning().score = self:battingTeamCurrentInning().score + scoredRunCount
|
||||||
|
|
||||||
|
self.announcer:say("SCORE!")
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param throwFlyMs number
|
---@param throwFlyMs number
|
||||||
---@return boolean didThrow
|
---@return boolean didThrow
|
||||||
local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
|
function Game:buttonControlledThrow(throwFlyMs, forbidThrowHome)
|
||||||
local targetBase
|
local targetBase
|
||||||
if playdate.buttonIsPressed(playdate.kButtonLeft) then
|
if playdate.buttonIsPressed(playdate.kButtonLeft) then
|
||||||
targetBase = C.Bases[C.Third]
|
targetBase = C.Bases[C.Third]
|
||||||
|
@ -232,62 +311,73 @@ local function buttonControlledThrow(throwFlyMs, forbidThrowHome)
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Power for this throw has already been determined
|
self.fielding:userThrowTo(targetBase, self.state.ball, throwFlyMs)
|
||||||
throwMeter:reset()
|
self.baserunning.secondsSinceLastRunnerMove = 0
|
||||||
|
self.state.offenseState = C.Offense.running
|
||||||
fielding:userThrowTo(targetBase, launchBall, throwFlyMs)
|
|
||||||
secondsSinceLastRunnerMove = 0
|
|
||||||
offenseState = C.Offense.running
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
local function nextBatter()
|
function Game:nextBatter()
|
||||||
secondsSincePitchAllowed = -3
|
pitchTracker.secondsSinceLastPitch = -3
|
||||||
baserunning.batter = nil
|
self.baserunning.batter = nil
|
||||||
playdate.timer.new(2000, function()
|
playdate.timer.new(2000, function()
|
||||||
pitchTracker:reset()
|
pitchTracker:reset()
|
||||||
if not baserunning.batter then
|
if not self.baserunning.batter then
|
||||||
baserunning:pushNewBatter()
|
self.baserunning:pushNewBatter()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function walk()
|
function Game:walk()
|
||||||
announcer:say("Walk!")
|
self.announcer:say("Walk!")
|
||||||
-- TODO? Use baserunning:convertBatterToRunner()
|
-- TODO? Use self.baserunning:convertBatterToRunner()
|
||||||
baserunning.batter.nextBase = C.Bases[C.First]
|
self.baserunning.batter.nextBase = C.Bases[C.First]
|
||||||
baserunning.batter.prevBase = C.Bases[C.Home]
|
self.baserunning.batter.prevBase = C.Bases[C.Home]
|
||||||
offenseState = C.Offense.walking
|
self.state.offenseState = C.Offense.walking
|
||||||
baserunning.batter = nil
|
self.baserunning.batter = nil
|
||||||
baserunning:updateForcedRunners()
|
self.baserunning:updateForcedRunners()
|
||||||
nextBatter()
|
self:nextBatter()
|
||||||
end
|
end
|
||||||
|
|
||||||
local function strikeOut()
|
function Game:strikeOut()
|
||||||
local outBatter = baserunning.batter
|
local outBatter = self.baserunning.batter
|
||||||
baserunning.batter = nil
|
self.baserunning.batter = nil
|
||||||
baserunning:outRunner(outBatter --[[@as Runner]], "Strike out!")
|
self.baserunning:outRunner(outBatter, "Strike out!")
|
||||||
nextBatter()
|
self:nextBatter()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local SwingBackDeg <const> = 30
|
||||||
|
local SwingForwardDeg <const> = 170
|
||||||
|
|
||||||
---@param batDeg number
|
---@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)
|
local batAngle = math.rad(batDeg)
|
||||||
-- TODO: animate bat-flip or something
|
-- TODO: animate bat-flip or something
|
||||||
batBase.x = baserunning.batter and (baserunning.batter.x + C.BatterHandPos.x) or 0
|
self.state.batBase.x = self.baserunning.batter and (self.baserunning.batter.x + C.BatterHandPos.x) or 0
|
||||||
batBase.y = baserunning.batter and (baserunning.batter.y + C.BatterHandPos.y) or 0
|
self.state.batBase.y = self.baserunning.batter and (self.baserunning.batter.y + C.BatterHandPos.y) or 0
|
||||||
batTip.x = batBase.x + (C.BatLength * math.sin(batAngle))
|
self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
|
||||||
batTip.y = batBase.y + (C.BatLength * math.cos(batAngle))
|
self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))
|
||||||
|
|
||||||
if
|
if
|
||||||
batSpeed > 0
|
batSpeed > 0
|
||||||
and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H)
|
and self.state.ball.y < 232
|
||||||
and 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
|
then
|
||||||
-- Hit!
|
-- Hit!
|
||||||
BatCrackReverb:play()
|
BatCrackReverb:play()
|
||||||
offenseState = C.Offense.running
|
self.state.offenseState = C.Offense.running
|
||||||
local ballAngle = batAngle + math.rad(90)
|
local ballAngle = batAngle + math.rad(90)
|
||||||
|
|
||||||
local mult = math.abs(batSpeed / 15)
|
local mult = math.abs(batSpeed / 15)
|
||||||
|
@ -297,52 +387,86 @@ local function updateBatting(batDeg, batSpeed)
|
||||||
ballVelX = ballVelX * -1
|
ballVelX = ballVelX * -1
|
||||||
ballVelY = ballVelY * -1
|
ballVelY = ballVelY * -1
|
||||||
end
|
end
|
||||||
local ballDestX = ball.x + (ballVelX * C.BattingPower)
|
local ballDestX = self.state.ball.x + (ballVelX * C.BattingPower)
|
||||||
local ballDestY = ball.y + (ballVelY * C.BattingPower)
|
local ballDestY = self.state.ball.y + (ballVelY * C.BattingPower)
|
||||||
pitchTracker:reset()
|
pitchTracker:reset()
|
||||||
local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
|
local flyTimeMs = 2000
|
||||||
launchBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler)
|
|
||||||
-- TODO? A dramatic eye-level view on a home-run could be sick.
|
-- 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
|
if utils.isFoulBall(ballDestX, ballDestY) then
|
||||||
announcer:say("Foul ball!")
|
self.announcer:say("Foul ball!")
|
||||||
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
|
pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
|
||||||
-- TODO: Have a fielder chase for the fly-out
|
-- TODO: Have a fielder chase for the fly-out
|
||||||
return
|
return
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param appliedSpeed number | fun(runner: Runner): number
|
---@param appliedSpeed number | fun(runner: Runner): number
|
||||||
---@return boolean someRunnerMoved
|
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
|
||||||
local function updateNonBatterRunners(appliedSpeed, forcedOnly)
|
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly)
|
||||||
local runnerMoved, runnersScored = baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
|
local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
|
||||||
|
self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, self.state.deltaSeconds)
|
||||||
if runnersScored ~= 0 then
|
if runnersScored ~= 0 then
|
||||||
score(runnersScored)
|
self:score(runnersScored)
|
||||||
end
|
end
|
||||||
return runnerMoved
|
return runnersStillMoving, secondsSinceLastRunnerMove
|
||||||
end
|
end
|
||||||
|
|
||||||
local function userPitch(throwFly)
|
function Game:returnToPitcher()
|
||||||
local aButton = playdate.buttonIsPressed(playdate.kButtonA)
|
self.fielding:resetFielderPositions()
|
||||||
local bButton = playdate.buttonIsPressed(playdate.kButtonB)
|
actionQueue:upsert("returnToPitcher", 60 * 1000, function()
|
||||||
if not aButton and not bButton then
|
while not self:pitcherIsOnTheMound() do
|
||||||
pitch(throwFly, 1)
|
coroutine.yield()
|
||||||
elseif aButton and not bButton then
|
end
|
||||||
pitch(throwFly, 2)
|
if not self.baserunning.batter then
|
||||||
elseif not aButton and bButton then
|
self.baserunning:pushNewBatter()
|
||||||
pitch(throwFly, 3)
|
end
|
||||||
elseif aButton and bButton then
|
self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
|
||||||
pitch(throwFly, 4)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
local function updateGameState()
|
function Game:updateGameState()
|
||||||
deltaSeconds = playdate.getElapsedTime() or 0
|
self.state.deltaSeconds = playdate.getElapsedTime() or 0
|
||||||
playdate.resetElapsedTime()
|
playdate.resetElapsedTime()
|
||||||
local crankChange = playdate.getCrankChange() --[[@as number]]
|
local crankChange = playdate.getCrankChange() --[[@as number]]
|
||||||
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
|
local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
|
||||||
|
@ -350,151 +474,183 @@ local function updateGameState()
|
||||||
crankLimited = crankLimited * -1
|
crankLimited = crankLimited * -1
|
||||||
end
|
end
|
||||||
|
|
||||||
ball:updatePosition()
|
self.state.ball:updatePosition()
|
||||||
|
|
||||||
local userOnOffense, userOnDefense = userIsOn(C.Sides.offense)
|
local userOnOffense, userOnDefense = self:userIsOn("offense")
|
||||||
|
|
||||||
if userOnDefense then
|
local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)
|
||||||
throwMeter:applyCharge(deltaSeconds, crankLimited)
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
if offenseState == C.Offense.batting then
|
if self.state.offenseState == C.Offense.batting then
|
||||||
if ball.y < C.StrikeZoneStartY then
|
pitchTracker:recordIfPassed(self.state.ball)
|
||||||
pitchTracker.recordedPitchX = nil
|
|
||||||
elseif not pitchTracker.recordedPitchX then
|
|
||||||
pitchTracker.recordedPitchX = ball.x
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
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
|
end
|
||||||
|
|
||||||
if secondsSincePitchAllowed > C.ReturnToPitcherAfterSeconds and not catcherThrownBall then
|
if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
|
||||||
local outcome = pitchTracker:updatePitchCounts()
|
local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
|
||||||
if outcome == PitchOutcomes.StrikeOut then
|
if outcome == PitchOutcomes.StrikeOut then
|
||||||
strikeOut()
|
self:strikeOut()
|
||||||
elseif outcome == PitchOutcomes.Walk then
|
elseif outcome == PitchOutcomes.Walk then
|
||||||
walk()
|
self:walk()
|
||||||
end
|
end
|
||||||
-- Catcher has the ball. Throw it back to the pitcher
|
self:returnToPitcher()
|
||||||
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
|
self.state.pitchIsOver = true
|
||||||
catcherThrownBall = true
|
self.state.didSwing = false
|
||||||
end
|
end
|
||||||
|
|
||||||
local batSpeed
|
local batSpeed
|
||||||
if userOnOffense then
|
if userOnOffense then
|
||||||
batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
|
self.state.batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
|
||||||
batSpeed = crankLimited
|
batSpeed = crankLimited
|
||||||
else
|
else
|
||||||
batAngleDeg = npc:updateBatAngle(ball, catcherThrownBall, deltaSeconds)
|
self.state.batAngleDeg =
|
||||||
batSpeed = npc:batSpeed() * deltaSeconds
|
self.npc:updateBatAngle(self.state.ball, self.state.pitchIsOver, self.state.deltaSeconds)
|
||||||
|
batSpeed = self.npc:batSpeed() * self.state.deltaSeconds
|
||||||
end
|
end
|
||||||
|
|
||||||
updateBatting(batAngleDeg, batSpeed)
|
self:updateBatting(self.state.batAngleDeg, batSpeed)
|
||||||
|
|
||||||
-- Walk batter to the plate
|
-- Walk batter to the plate
|
||||||
-- TODO: Ensure batter can't be nil, here
|
self.baserunning:updateRunner(self.baserunning.batter, nil, userOnOffense and crankLimited or 0, self.state.deltaSeconds)
|
||||||
baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds)
|
|
||||||
|
|
||||||
if secondsSincePitchAllowed > C.PitchAfterSeconds then
|
if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
|
||||||
if userOnDefense then
|
if userOnDefense then
|
||||||
local throwFly = throwMeter:readThrow()
|
local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange)
|
||||||
if throwFly and not buttonControlledThrow(throwFly, true) then
|
if powerRatio then
|
||||||
userPitch(throwFly)
|
local throwFly = C.PitchFlyMs / powerRatio
|
||||||
|
if throwFly and not self:buttonControlledThrow(throwFly, true) then
|
||||||
|
self:userPitch(throwFly, accuracy)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
pitch(C.PitchFlyMs / npc:pitchSpeed(), math.random(#Pitches))
|
self:pitch(C.PitchFlyMs / self.npc:pitchSpeed(), math.random(#Pitches))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif offenseState == C.Offense.running then
|
elseif self.state.offenseState == C.Offense.running then
|
||||||
local appliedSpeed = userOnOffense and crankLimited
|
local appliedSpeed = userOnOffense and crankLimited
|
||||||
or function(runner)
|
or function(runner)
|
||||||
return npc:runningSpeed(runner, ball)
|
return self.npc:runningSpeed(runner, self.state.ball)
|
||||||
end
|
end
|
||||||
if updateNonBatterRunners(appliedSpeed) then
|
local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false)
|
||||||
secondsSinceLastRunnerMove = 0
|
|
||||||
else
|
|
||||||
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
|
|
||||||
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
|
if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
|
||||||
-- End of play. Throw the ball back to the pitcher
|
-- End of play. Throw the ball back to the pitcher
|
||||||
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
|
self.state.offenseState = C.Offense.batting
|
||||||
fielding:markAllIneligible() -- This is ugly, and ideally would not be necessary if Fielding handled the return throw directly.
|
self:returnToPitcher()
|
||||||
fielding:resetFielderPositions()
|
|
||||||
offenseState = C.Offense.batting
|
|
||||||
-- TODO: Remove, or replace with nextBatter()
|
|
||||||
if not baserunning.batter then
|
|
||||||
baserunning:pushNewBatter()
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif offenseState == C.Offense.walking then
|
|
||||||
if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
|
|
||||||
offenseState = C.Offense.batting
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds)
|
|
||||||
|
|
||||||
if userOnDefense then
|
if userOnDefense then
|
||||||
local throwFly = throwMeter:readThrow()
|
local powerRatio, accuracy, isPerfect = throwMeter:readThrow(crankChange)
|
||||||
|
if powerRatio then
|
||||||
|
local throwFly = C.PitchFlyMs / powerRatio
|
||||||
if throwFly then
|
if throwFly then
|
||||||
buttonControlledThrow(throwFly)
|
self:buttonControlledThrow(throwFly)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if fielderHoldingBall then
|
end
|
||||||
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
|
elseif self.state.offenseState == C.Offense.walking then
|
||||||
if not userOnDefense and offenseState == C.Offense.running then
|
if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
|
||||||
npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
baserunning:walkAwayOutRunners(deltaSeconds)
|
self.baserunning:walkAwayOutRunners(self.state.deltaSeconds)
|
||||||
actionQueue:runWaiting(deltaSeconds)
|
actionQueue:runWaiting(self.state.deltaSeconds)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- TODO: Swappable update() for main menu, etc.
|
function Game:update()
|
||||||
|
|
||||||
function playdate.update()
|
|
||||||
playdate.timer.updateTimers()
|
playdate.timer.updateTimers()
|
||||||
gfx.animation.blinker.updateAll()
|
gfx.animation.blinker.updateAll()
|
||||||
updateGameState()
|
self:updateGameState()
|
||||||
|
local ball = self.state.ball
|
||||||
|
|
||||||
gfx.clear()
|
gfx.clear()
|
||||||
gfx.setColor(gfx.kColorBlack)
|
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)
|
gfx.setDrawOffset(offsetX, offsetY)
|
||||||
|
|
||||||
GrassBackground:draw(-400, -240)
|
GrassBackground:draw(-400, -720)
|
||||||
|
|
||||||
local ballIsHeld = fielding:drawFielders(fieldingTeamSprites, ball)
|
---@type { y: number, drawAction: fun() }[]
|
||||||
|
local characterDraws = {}
|
||||||
if offenseState == C.Offense.batting then
|
function addDraw(y, drawAction)
|
||||||
gfx.setLineWidth(5)
|
characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
|
||||||
gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local playerHeightOffset = 10
|
local danceOffset = FielderDanceAnimator:currentValue()
|
||||||
-- TODO? Scale sprites down as y increases
|
---@type Fielder | nil
|
||||||
for _, runner in pairs(baserunning.runners) do
|
local ballHeldBy
|
||||||
if runner == baserunning.batter then
|
for _, fielder in pairs(self.fielding.fielders) do
|
||||||
if batAngleDeg > 50 and batAngleDeg < 200 then
|
addDraw(fielder.y + danceOffset, function()
|
||||||
battingTeamSprites.back:draw(runner.x, runner.y - playerHeightOffset)
|
local ballHeldByThisFielder =
|
||||||
|
drawFielder(self.state.fieldingTeamSprites, self.state.ball, fielder.x, fielder.y + danceOffset)
|
||||||
|
if ballHeldByThisFielder then
|
||||||
|
ballHeldBy = fielder
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
else
|
||||||
battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
|
self.state.battingTeamSprites.smiling:draw(runner.x, runner.y - playerHeightOffset)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- TODO? Change blip speed depending on runner speed?
|
-- TODO? Change blip speed depending on runner speed?
|
||||||
runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
|
self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset)
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, runner in pairs(baserunning.outRunners) do
|
table.sort(characterDraws, function(a, b)
|
||||||
battingTeamSprites.frowning:draw(runner.x, runner.y - playerHeightOffset)
|
return a.y < b.y
|
||||||
|
end)
|
||||||
|
for _, character in pairs(characterDraws) do
|
||||||
|
character.drawAction()
|
||||||
end
|
end
|
||||||
|
|
||||||
if not ballIsHeld then
|
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.setLineWidth(2)
|
||||||
|
|
||||||
gfx.setColor(gfx.kColorWhite)
|
gfx.setColor(gfx.kColorWhite)
|
||||||
|
@ -506,31 +662,30 @@ function playdate.update()
|
||||||
|
|
||||||
gfx.setDrawOffset(0, 0)
|
gfx.setDrawOffset(0, 0)
|
||||||
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
|
if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
|
||||||
drawMinimap(baserunning.runners, fielding.fielders)
|
drawMinimap(self.baserunning.runners, self.fielding.fielders)
|
||||||
end
|
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)
|
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
|
if playdate.isCrankDocked() then
|
||||||
|
-- luacheck: ignore
|
||||||
playdate.ui.crankIndicator:draw()
|
playdate.ui.crankIndicator:draw()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function init()
|
playdate.display.setRefreshRate(50)
|
||||||
playdate.display.setRefreshRate(50)
|
gfx.setBackgroundColor(gfx.kColorWhite)
|
||||||
gfx.setBackgroundColor(gfx.kColorWhite)
|
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
|
||||||
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
|
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
|
||||||
fielding:resetFielderPositions(teams.home.benchPosition)
|
|
||||||
playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?
|
|
||||||
|
|
||||||
playdate.timer.new(2000, function()
|
MainMenu.start(Game)
|
||||||
launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false)
|
|
||||||
end)
|
|
||||||
BootTune:play()
|
|
||||||
BootTune:setFinishCallback(function()
|
|
||||||
TinnyBackground:play()
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
init()
|
|
||||||
|
|
30
src/npc.lua
|
@ -18,16 +18,18 @@ function Npc.new(runners, fielders)
|
||||||
}, { __index = Npc })
|
}, { __index = Npc })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- TODO: FAR more nuanced NPC batting.
|
||||||
---@param ball XyPair
|
---@param ball XyPair
|
||||||
---@param catcherThrownBall boolean
|
---@param pitchIsOver boolean
|
||||||
---@param deltaSec number
|
---@param deltaSec number
|
||||||
---@return number
|
---@return number
|
||||||
function Npc:updateBatAngle(ball, catcherThrownBall, deltaSec)
|
-- luacheck: no unused
|
||||||
if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < C.Center.x + 15) then
|
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)
|
npcBatDeg = npcBatDeg + (deltaSec * npcBatSpeed)
|
||||||
else
|
else
|
||||||
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
|
npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
|
||||||
npcBatDeg = 200
|
npcBatDeg = 230
|
||||||
end
|
end
|
||||||
return npcBatDeg
|
return npcBatDeg
|
||||||
end
|
end
|
||||||
|
@ -119,35 +121,31 @@ end
|
||||||
---@param fielders Fielder[]
|
---@param fielders Fielder[]
|
||||||
---@param fielder Fielder
|
---@param fielder Fielder
|
||||||
---@param runners Runner[]
|
---@param runners Runner[]
|
||||||
---@param launchBall LaunchBall
|
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
|
||||||
local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall)
|
local function tryToMakeAPlay(fielders, fielder, runners, ball)
|
||||||
local targetX, targetY = getNextOutTarget(runners)
|
local targetX, targetY = getNextOutTarget(runners)
|
||||||
if targetX ~= nil and targetY ~= nil then
|
if targetX ~= nil and targetY ~= nil then
|
||||||
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY, function(grabCandidate)
|
local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
|
||||||
return grabCandidate.catchEligible
|
|
||||||
end)
|
|
||||||
nearestFielder.target = utils.xy(targetX, targetY)
|
nearestFielder.target = utils.xy(targetX, targetY)
|
||||||
if nearestFielder == fielder then
|
if nearestFielder == fielder then
|
||||||
ball.heldBy = fielder
|
ball.heldBy = fielder
|
||||||
else
|
else
|
||||||
launchBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
|
ball:launch(targetX, targetY, playdate.easingFunctions.linear, nil, true)
|
||||||
Fielding.markIneligible(nearestFielder)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param fielder Fielder
|
---@param fielder Fielder
|
||||||
---@param outedSomeRunner boolean
|
---@param outedSomeRunner boolean
|
||||||
---@param ball { x: number, y: number, heldBy: Fielder | nil }
|
---@param ball { x: number, y: number, heldBy: Fielder | nil, launch: LaunchBall }
|
||||||
---@param launchBall LaunchBall
|
function Npc:fielderAction(fielder, outedSomeRunner, ball)
|
||||||
function Npc:fielderAction(fielder, outedSomeRunner, ball, launchBall)
|
|
||||||
if outedSomeRunner then
|
if outedSomeRunner then
|
||||||
-- Delay a little before the next play
|
-- Delay a little before the next play
|
||||||
playdate.timer.new(750, function()
|
playdate.timer.new(750, function()
|
||||||
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall)
|
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
tryToMakeAPlay(self.fielders, fielder, self.runners, ball, launchBall)
|
tryToMakeAPlay(self.fielders, fielder, self.runners, ball)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -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 <const> = playdate.graphics
|
||||||
|
|
||||||
|
local StrikeZoneWidth <const> = 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
|
|
@ -11,7 +11,7 @@ function buildBaserunning()
|
||||||
return baserunning, thirdOutCallbackData
|
return baserunning, thirdOutCallbackData
|
||||||
end
|
end
|
||||||
|
|
||||||
---@alias BaseIndexOrXyPair (integer | XyPair)
|
---@alias BaseIndexOrXyPair (number | XyPair)
|
||||||
|
|
||||||
--- NOTE: in addition to the given runners, there is implicitly a batter running from first.
|
--- NOTE: in addition to the given runners, there is implicitly a batter running from first.
|
||||||
---@param runnerLocations BaseIndexOrXyPair[]
|
---@param runnerLocations BaseIndexOrXyPair[]
|
||||||
|
@ -44,7 +44,7 @@ end
|
||||||
|
|
||||||
---@param expected boolean
|
---@param expected boolean
|
||||||
---@param fielderWithBallAt XyPair
|
---@param fielderWithBallAt XyPair
|
||||||
---@param when integer[][]
|
---@param when number[][]
|
||||||
function assertRunnerOutCondition(expected, when, fielderWithBallAt)
|
function assertRunnerOutCondition(expected, when, fielderWithBallAt)
|
||||||
local msg = expected and "out" or "safe"
|
local msg = expected and "out" or "safe"
|
||||||
for _, runnersOn in ipairs(when) do
|
for _, runnersOn in ipairs(when) do
|
||||||
|
|
120
src/utils.lua
|
@ -1,4 +1,4 @@
|
||||||
-- selene: allow(unscoped_variables)
|
-- luacheck no new globals
|
||||||
utils = {}
|
utils = {}
|
||||||
|
|
||||||
--- @alias XyPair {
|
--- @alias XyPair {
|
||||||
|
@ -70,17 +70,28 @@ end
|
||||||
---@param mover { x: number, y: number }
|
---@param mover { x: number, y: number }
|
||||||
---@param speed number
|
---@param speed number
|
||||||
---@param target { x: number, y: number }
|
---@param target { x: number, y: number }
|
||||||
---@return boolean
|
---@param tau number | nil
|
||||||
function utils.moveAtSpeed(mover, speed, target)
|
---@return boolean isStillMoving
|
||||||
|
function utils.moveAtSpeed(mover, speed, target, tau)
|
||||||
local x, y, distance = utils.normalizeVector(mover.x, mover.y, target.x, target.y)
|
local x, y, distance = utils.normalizeVector(mover.x, mover.y, target.x, target.y)
|
||||||
|
|
||||||
if distance > 1 then
|
if distance == 0 then
|
||||||
mover.x = mover.x - (x * speed)
|
return false
|
||||||
mover.y = mover.y - (y * speed)
|
|
||||||
return true
|
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
---@generic T
|
---@generic T
|
||||||
|
@ -164,6 +175,22 @@ function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, li
|
||||||
return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
|
return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
|
||||||
end
|
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.
|
--- Returns true only if the point is below the given line.
|
||||||
---@return boolean
|
---@return boolean
|
||||||
function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
|
function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)
|
||||||
|
@ -216,76 +243,19 @@ function utils.getNearestOf(array, x, y, extraCondition)
|
||||||
return nearest, nearestDistance
|
return nearest, nearestDistance
|
||||||
end
|
end
|
||||||
|
|
||||||
-- selene: allow(unscoped_variables)
|
---@param stats Statistics
|
||||||
PitchOutcomes = {
|
---@return number homeScore, number awayScore
|
||||||
StrikeOut = {},
|
function utils.totalScores(stats)
|
||||||
Walk = {},
|
local homeScore = 0
|
||||||
}
|
local awayScore = 0
|
||||||
|
for _, inning in pairs(stats.innings) do
|
||||||
-- selene: allow(unscoped_variables)
|
homeScore = homeScore + inning.home.score
|
||||||
pitchTracker = {
|
awayScore = awayScore + inning.away.score
|
||||||
--- 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
|
end
|
||||||
|
|
||||||
if self.recordedPitchX > C.StrikeZoneStartX and self.recordedPitchX < C.StrikeZoneEndX then
|
return homeScore, awayScore
|
||||||
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))
|
|
||||||
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
|
end
|
||||||
|
|
||||||
if not playdate then
|
if not playdate then
|
||||||
return utils, { pitchTracker = pitchTracker, PitchOutcomes = PitchOutcomes, throwMeter = throwMeter }
|
return utils
|
||||||
end
|
end
|
||||||
|
|