Add .luarc.json for library reading.

Move more stuff to utils.
More type hints.
*Some* fleshing-out of getNextThrowTarget() etc.
Closer to proper multi-baserunner support.
This commit is contained in:
Sage Vaillancourt 2025-01-30 00:18:02 -05:00
parent 4093f9705a
commit 533c625d47
3 changed files with 241 additions and 133 deletions

8
.luarc.json Normal file
View File

@ -0,0 +1,8 @@
{
"Lua.runtime.version": "Lua 5.4",
"Lua.diagnostics.disable": ["undefined-global", "lowercase-global"],
"Lua.diagnostics.globals": ["playdate", "import"],
"Lua.runtime.nonstandardSymbol": ["+=", "-=", "*=", "/="],
"Lua.workspace.library": ["/home/sage/Downloads/PlaydateSDK-2.6.2/CoreLibs"],
"Lua.workspace.preloadFileSize": 1000
}

View File

@ -12,21 +12,22 @@ import 'utils.lua'
--- @alias Runner { x: number, y: number, nextBase: Base, prevBase: Base, forcedTo: Base }
--- @alias Fielder { onArrive: fun() | nil, x: number | nil, y: number | nil, targetX: number | nil, targetY: number | nil }
--- @alias Fielder { onArrive: fun() | nil, x: number | nil, y: number | nil, target: Position | nil, speed: number }
local gfx = playdate.graphics
playdate.display.setRefreshRate(50)
gfx.setBackgroundColor(gfx.kColorWhite)
playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
local grassBackground = gfx.image.new("images/game/grass.png")
local grassBackground = gfx.image.new("images/game/grass.png") or {}
local playerImageBlipper = blipper.new(
100,
100,
"images/game/player.png",
"images/game/player-lowhat.png"
)
---@type Position
local backgroundPan = {
x = 0,
y = 0,
@ -48,7 +49,7 @@ local batLength = 45
local tagDistance = 20
local ballY = ballStartY
local ballY = 999 -- ballStartY
local ballX = 200
local ballSize = 6
@ -83,54 +84,63 @@ local nextBaseMap = {
[bases.third] = bases.home
}
local fielderSpeed = 40
---@type table<string, Fielder>
local fielders = {
first = {
x = nil,
y = nil,
speed = 40,
},
second = {},
shortstop = {},
third = {},
pitcher = {},
left = {},
center = {},
right = {}
second = {
speed = 40,
},
shortstop = {
speed = 40,
},
third = {
speed = 40,
},
pitcher = {
speed = 30,
},
catcher = {
speed = 20,
},
left = {
speed = 40,
},
center = {
speed = 40,
},
right = {
speed = 40,
}
}
function resetFielderPositions()
fielders.first.x = screenW - 65
fielders.first.y = screenH * 0.48
--- Resets the target positions of all fielders to their defaults (at their field positions).
---@param fromOffTheField boolean If provided, also sets all runners' current position to one centralized location.
function resetFielderPositions(fromOffTheField)
if fromOffTheField then
for _,fielder in pairs(fielders) do
fielder.x = centerX
fielder.y = screenH
end
end
fielders.second.x = screenW * 0.70
fielders.second.y = screenH * 0.30
fielders.shortstop.x = screenW * 0.30
fielders.shortstop.y = screenH * 0.30
fielders.third.x = screenW * 0.1
fielders.third.y = screenH * 0.48
fielders.pitcher.x = screenW * 0.48
fielders.pitcher.y = screenH * 0.40
fielders.left.x = screenW * -1
fielders.left.y = screenH * -0.2
fielders.center.x = fielders.second.x
fielders.center.y = screenH * -0.4
fielders.right.x = screenW * 2
fielders.right.y = screenH * fielders.left.y
fielders.first.target = { x = screenW - 65, y = screenH * 0.48 }
fielders.second.target = { x = screenW * 0.70, y = screenH * 0.30 }
fielders.shortstop.target = { x = screenW * 0.30, y = screenH * 0.30 }
fielders.third.target = { x = screenW * 0.1, y = screenH * 0.48 }
fielders.pitcher.target = { x = screenW * 0.48, y = screenH * 0.40 }
fielders.catcher.target = { x = screenW * 0.475, y = screenH * 0.92 }
fielders.left.target = { x = screenW * -1, y = screenH * -0.2 }
fielders.center.target = { x = centerX, y = screenH * -0.4 }
fielders.right.target = { x = screenW * 2, y = screenH * fielders.left.target.y }
end
resetFielderPositions()
resetFielderPositions(true)
local playerStartingX = bases.home.x - 40
local playerStartingY = bases.home.y - 3
--- @type table<Runner, Runner>
--- @type Runner[]
local runners = { }
---@return Runner
@ -145,6 +155,7 @@ function newRunner()
return new
end
---@type Runner | nil
local batter = newRunner()
function throwBall(destX, destY, easingFunc, flyTimeMs)
@ -158,18 +169,12 @@ end
function pitch()
pitchAnimator:reset()
resetFielderPositions()
ballY = ballStartY
ballX = 200
currentMode = MODES.batting
backgroundPan.y = 0
backgroundPan.x = 0
-- TODO: Add new runners, instead
--runners = {}
--batter.x = playerStartingX
--batter.y = playerStartingY
batter = newRunner()
end
function playdate.AButtonDown()
@ -177,19 +182,19 @@ function playdate.AButtonDown()
end
function playdate.upButtonDown()
batBaseY -= 1
batBaseY = batBaseY - 1
end
function playdate.downButtonDown()
batBaseY += 1
batBaseY = batBaseY + 1
end
function playdate.rightButtonDown()
batBaseX += 1
batBaseX = batBaseX + 1
end
function playdate.leftButtonDown()
batBaseX -= 1
batBaseX = batBaseX - 1
end
local pitchClockSec = 99
@ -211,8 +216,6 @@ end
local ballCatchHitbox = 3
function isTouchingBall(x, y)
local ballDistance = distanceBetween(x, y, ballX, ballY)
-- print("ballDistance:")
-- print(ballDistance)
return ballDistance < ballCatchHitbox
end
@ -224,31 +227,34 @@ function outRunner(runnerIndex)
updateForcedTos()
end
function updateInfield()
if ballDestX == nil or ballDestY == nil then
return
end
function updateFielders()
local touchingBaseCache = buildCache(
function(runner)
return isTouchingBase(runner.x, runner.y)
end
)
for _,fielder in pairs(fielders) do
if fielder.targetX ~= nil and fielder.targetY ~= nil then
local x, y, distance = normalizeVector(fielder.x, fielder.y, fielder.targetX, fielder.targetY)
-- TODO: Target unforced runners (or their target bases) for tagging
-- With new Position-based scheme, fielders are now able to set `fielder.target = runner` to track directly
if fielder.target ~= nil then
local x, y, distance = normalizeVector(fielder.x, fielder.y, fielder.target.x, fielder.target.y)
if distance > 1 then
fielder.x -= x * fielderSpeed * deltaTime
fielder.y -= y * fielderSpeed * deltaTime
fielder.x = fielder.x - (x * fielder.speed * deltaTime)
fielder.y = fielder.y - (y * fielder.speed * deltaTime)
else
if fielder.onArrive then
fielder.onArrive()
end
fielder.targetX = nil
fielder.targetY = nil
fielder.target = nil
end
end
if isTouchingBall(fielder.x, fielder.y) then
local touchedBase = isTouchingBase(fielder.x, fielder.y)
for i,runner in pairs(runners) do
local runnerOnBase = isTouchingBase(runner.x, runner.y)
local runnerOnBase = touchingBaseCache.get(runner)
if touchedBase and runner.forcedTo == touchedBase and touchedBase ~= runnerOnBase then
outRunner(i)
elseif not runnerOnBase then
@ -262,37 +268,18 @@ function updateInfield()
end
end
--- Returns the nearest base, as well as the distance from that base
---@generic T : {x: number, y: number}
---@param array T[]
---@param x: number
---@param y: number
---@return T,number
function getNearestOf(array, x, y)
local nearest, nearestDistance = nil, nil
for _, element in pairs(array) do
if nearest == nil then
nearest = element
nearestDistance = distanceBetween(element.x, element.y, x, y)
else
local distance = distanceBetween(element.x, element.y, x, y)
if distance < nearestDistance then
nearest = element
nearestDistance = distance
end
end
end
return nearest, nearestDistance
end
--- Returns true if at least one runner is still moving
---@return boolean
function updateRunners()
local runnerSpeed = 20
local autoRunSpeed = 20
--autoRunSpeed = 140
local nonPlayerRunners = filter(runners, function (runner)
return runner ~= batter
end)
local runnerMoved = false
for _,runner in pairs(nonPlayerRunners) do
local nearestBase, nearestBaseDistance = getNearestOf(bases, runner.x, runner.y)
local _, nearestBaseDistance = getNearestOf(bases, runner.x, runner.y)
if runner.nextBase then
local nb = runner.nextBase
local x, y, distance = normalizeVector(runner.x, runner.y, nb.x, nb.y)
@ -302,17 +289,21 @@ function updateRunners()
if crankChange < 0 then
mult = -1
end
local prevX, prevY = runner.x, runner.y
-- TODO: Drift toward nearest base?
local autoRun = nearestBaseDistance > 5 and mult * runnerSpeed * deltaTime or 0
local autoRun = nearestBaseDistance > 5 and mult * autoRunSpeed * deltaTime or 0
mult = autoRun + (crankChange / 20)
runner.x -= x * mult
runner.y -= y * mult
runner.x = runner.x - (x * mult)
runner.y = runner.y - (y * mult)
runnerMoved = runnerMoved or prevX ~= runner.x or prevY ~= runner.y
else
runner.nextBase = nextBaseMap[runner.nextBase]
runner.forcedTo = nil
end
end
end
return runnerMoved
end
---@return boolean
@ -325,32 +316,82 @@ function throwArrivedBeforeRunner()
return false
end
function getRunnerTargeting(base)
for _,runner in pairs(runners) do
if runner.nextBase == base then
return runner
end
end
return nil
end
---@return Base[]
function getForcedOutTargets()
return { bases.first }
local targets = {}
for _,base in pairs(bases) do
local runnerTargetingBase = getRunnerTargeting(base)
if runnerTargetingBase then
targets[#targets+1] = base
else
return targets
end
end
return targets
-- return { bases.first }
end
---@return number,number
function getNextThrowTarget()
local targets = getForcedOutTargets()
return targets[1].x, targets[1].y
--- Returns the position,distance of the basest closest to the runner furthest from a base
function getBaseOfStrandedRunner()
local farRunnersBase, farDistance
for _,runner in pairs(runners) do
local base, distance = getNearestOf(bases, runner.x, runner.y, function(base)
return runner.nextBase == base
end)
if farRunnersBase == nil then
farRunnersBase = base
farDistance = distance
elseif farDistance < distance then
farRunnersBase = base
farDistance = distance
end
end
return farRunnersBase, farDistance
end
--- Returns x,y of the throw target
---@return number|nil, number|nil
function getNextThrowTarget()
-- TODO: Handle missed throws, check for fielders at target, etc.
local targets = getForcedOutTargets()
print("forcedOutTargets:")
printTable(targets)
if #targets ~= 0 then
return targets[1].x, targets[1].y
end
-- local baseCloseToStrandedRunner = getBaseOfStrandedRunner()
-- return baseCloseToStrandedRunner.x, baseCloseToStrandedRunner.y
end
local resetFieldersAfterSeconds = 5
local secondsSinceLastRunnerMove = 0
function updateGameState()
deltaTime = playdate.getElapsedTime()
deltaTime = playdate.getElapsedTime() or 0
playdate.resetElapsedTime()
elapsedTime = elapsedTime + deltaTime
if elapsedTime > pitchClockSec then
elapsedTime = 0
pitch()
end
-- if elapsedTime > pitchClockSec then
-- elapsedTime = 0
-- pitch()
-- end
if currentMode == MODES.running then
ballX = hitAnimatorX:currentValue()
ballY = hitAnimatorY:currentValue()
ballSize = ballSizeAnimator:currentValue()
else
elseif ballY < 999 then
ballY = pitchAnimator:currentValue()
ballSize = 6
end
@ -375,25 +416,36 @@ function updateGameState()
ballDestX = ballX + (ballVelX * hitMult)
ballDestY = ballY + (ballVelY * hitMult)
throwBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000)
local chasingFielder = getNearestOf(fielders, ballDestX, ballDestY)
chasingFielder.targetX = ballDestX
chasingFielder.targetY = ballDestY
chasingFielder.onArrive = function()
local targetX, targetY = getNextThrowTarget()
throwBall(targetX, targetY, playdate.easingFunctions.linear)
chasingFielder.onArrive = nil
end
fielders.first.targetX = bases.first.x
fielders.first.targetY = bases.first.y
fielders.first.target = bases.first
batter.nextBase = bases.first
batter.forcedTo = bases.first
batter = nil -- Demote batter to a mere runner
local chasingFielder = getNearestOf(fielders, ballDestX, ballDestY)
chasingFielder.target = { x = ballDestX, y = ballDestY }
chasingFielder.onArrive = function()
local targetX, targetY = getNextThrowTarget()
if targetX ~= nil then
throwBall(targetX, targetY, playdate.easingFunctions.linear)
chasingFielder.onArrive = nil
end
end
end
if currentMode == MODES.running then
updateRunners()
updateInfield()
if updateRunners() then
secondsSinceLastRunnerMove = 0
else
secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaTime
if secondsSinceLastRunnerMove > resetFieldersAfterSeconds then
resetFielderPositions(false)
currentMode = MODES.batting
batter = newRunner()
end
end
end
updateFielders()
end
function playdate.update()
@ -443,6 +495,7 @@ function playdate.update()
-- TODO? Change blip speed depending on runner speed?
for _,runner in pairs(runners) do
-- TODO? Scale sprites down as y increases
playerImageBlipper:draw(
false,
runner.x + backgroundPan.x,

View File

@ -25,7 +25,7 @@ end
---@generic TIn
---@generic TOut
---@param array TIn[]
---@param condition fun(TIn): TOut
---@param mapper fun(TIn): TOut
---@return TOut[]
function map(array, mapper)
local newArray = {}
@ -42,6 +42,8 @@ function distanceBetween(x1, y1, x2, y2)
return math.sqrt((a*a) + (b*b)), a, b
end
--- Returns true only if the point is below the given line, within the x bounds of said line, and above the bottomBound
--- @return boolean
function pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2, bottomBound)
-- This check currently assumes right-handedness.
-- I.e. it assumes the ball is to the right of batBaseX
@ -61,20 +63,65 @@ function pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2,
return yDelta <= 0
end
blipper = {
--- Build an object that simply "blips" between the given images at the given interval.
--- Expects `playdate.graphics.animation.blinker.updateAll()` to be called on every update.
new = function(msInterval, imagePath1, imagePath2)
local blinker = playdate.graphics.animation.blinker.new(msInterval, msInterval, true)
blinker:start()
return {
blinker = blinker,
image1 = playdate.graphics.image.new(imagePath1),
image2 = playdate.graphics.image.new(imagePath2),
draw = function(self, disableBlipping, x, y)
local currentImage = (disableBlipping or self.blinker.on) and self.image2 or self.image1
currentImage:draw(x, y)
end
}
--- Returns the nearest position object from the given point, as well as its distance from that point
---@generic T : {x: number, y: number | nil}
---@param array T[]
---@param x number
---@param y number
---@return T,number|nil
function getNearestOf(array, x, y, extraCondition)
local nearest, nearestDistance = nil, nil
for _, element in pairs(array) do
if not extraCondition or extraCondition(element) then
if nearest == nil then
nearest = element
nearestDistance = distanceBetween(element.x, element.y, x, y)
else
local distance = distanceBetween(element.x, element.y, x, y)
if distance < nearestDistance then
nearest = element
nearestDistance = distance
end
end
end
end
}
return nearest, nearestDistance
end
local NO_VALUE = {}
function buildCache(fetcher)
local cacheData = {}
return {
cacheDate = cacheData,
get = function(key)
if cacheData[key] == NO_VALUE then
return nil
end
if cacheData[key] ~= nil then
return cacheData[key]
end
cacheData[key] = fetcher(key) or NO_VALUE
return cacheData[key]
end
}
end
blipper = {}
--- Build an object that simply "blips" between the given images at the given interval.
--- Expects `playdate.graphics.animation.blinker.updateAll()` to be called on every update.
function blipper.new(msInterval, imagePath1, imagePath2)
local blinker = playdate.graphics.animation.blinker.new(msInterval, msInterval, true)
blinker:start()
return {
blinker = blinker,
image1 = playdate.graphics.image.new(imagePath1),
image2 = playdate.graphics.image.new(imagePath2),
draw = function(self, disableBlipping, x, y)
local currentImage = (disableBlipping or self.blinker.on) and self.image2 or self.image1
currentImage:draw(x, y)
end
}
end