From 533c625d47b5cdd8e271b1ce10929b1d9d0aec7c Mon Sep 17 00:00:00 2001 From: Sage Vaillancourt Date: Thu, 30 Jan 2025 00:18:02 -0500 Subject: [PATCH] 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. --- .luarc.json | 8 ++ src/main.lua | 285 ++++++++++++++++++++++++++++++-------------------- src/utils.lua | 81 +++++++++++--- 3 files changed, 241 insertions(+), 133 deletions(-) create mode 100644 .luarc.json diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..8d52fef --- /dev/null +++ b/.luarc.json @@ -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 +} \ No newline at end of file diff --git a/src/main.lua b/src/main.lua index 2fcd395..7386f14 100644 --- a/src/main.lua +++ b/src/main.lua @@ -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 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 +--- @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, diff --git a/src/utils.lua b/src/utils.lua index b7069c9..67af74a 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -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 -} \ No newline at end of file + + 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 \ No newline at end of file