diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..b47f536 --- /dev/null +++ b/selene.toml @@ -0,0 +1 @@ +std = "lua53" \ No newline at end of file diff --git a/src/action-queue.lua b/src/action-queue.lua index 2cfcb5e..3df7fcf 100644 --- a/src/action-queue.lua +++ b/src/action-queue.lua @@ -1,36 +1,28 @@ ----@alias ActionResult {} - ----@type table --- selene: allow(unscoped_variables) -ActionResult = { - Succeeded = {}, - Failed = {}, - NeedsMoreTime = {}, -} - -- selene: allow(unscoped_variables) actionQueue = { - ---@type ({ action: Action, expireTimeMs: number })[] + ---@type table queue = {}, } ----@alias Action fun(deltaSeconds: number): ActionResult +---@alias Action fun(deltaSeconds: number) + +--selene: allow(incorrect_standard_library_use) +local close = coroutine.close --- Added actions will be called on every runWaiting() update. --- 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. ----@param name string +---@param id any ---@param maxTimeMs number ---@param action Action -function actionQueue:upsert(name, maxTimeMs, action) - if action(0) ~= ActionResult.NeedsMoreTime then - return +function actionQueue:upsert(id, maxTimeMs, action) + if self.queue[id] then + close(self.queue[id].coroutine) end - - self.queue[name] = { - action = action, + self.queue[id] = { + coroutine = coroutine.create(action), expireTimeMs = maxTimeMs + playdate.getCurrentTimeMilliseconds(), } end @@ -39,10 +31,16 @@ end --- Actions that return NeedsMoreTime will not be removed from the queue unless they have expired. function actionQueue:runWaiting(deltaSeconds) local currentTimeMs = playdate.getCurrentTimeMilliseconds() - for name, actionObject in pairs(self.queue) do - local result = actionObject.action(deltaSeconds) - if result ~= ActionResult.NeedsMoreTime or currentTimeMs > actionObject.expireTimeMs then - self.queue[name] = nil + + for id, actionObject in pairs(self.queue) do + coroutine.resume(actionObject.coroutine, deltaSeconds) + + if currentTimeMs > actionObject.expireTimeMs then + close(actionObject.coroutine) + end + + if coroutine.status(actionObject.coroutine) == "dead" then + self.queue[id] = nil end end end diff --git a/src/assets.lua b/src/assets.lua index 4ff3650..bbfc725 100644 --- a/src/assets.lua +++ b/src/assets.lua @@ -15,6 +15,9 @@ PlayerFrown = playdate.graphics.image.new("images/game/PlayerFrown.png") GloveHoldingBall = playdate.graphics.image.new("images/game/GloveHoldingBall.png") --selene: allow(unused_variable) --selene: allow(unscoped_variables) +GameLogo = playdate.graphics.image.new("images/game/GameLogo.png") +--selene: allow(unused_variable) +--selene: allow(unscoped_variables) Hat = playdate.graphics.image.new("images/game/Hat.png") --selene: allow(unused_variable) --selene: allow(unscoped_variables) @@ -57,30 +60,31 @@ TinnyBackground = playdate.sound.sampleplayer.new("music/TinnyBackground.wav") Logos = { --selene: allow(unused_variable) --selene: allow(unscoped_variables) -Cats = playdate.graphics.image.new("images/game/logos/Cats.png"), +{ name = "Base", image = playdate.graphics.image.new("images/game/logos/Base.png") }, + --selene: allow(unused_variable) --selene: allow(unscoped_variables) -Hearts = playdate.graphics.image.new("images/game/logos/Hearts.png"), +{ name = "Cats", image = playdate.graphics.image.new("images/game/logos/Cats.png") }, --selene: allow(unused_variable) --selene: allow(unscoped_variables) -Checkmarks = playdate.graphics.image.new("images/game/logos/Checkmarks.png"), +{ name = "Hearts", image = playdate.graphics.image.new("images/game/logos/Hearts.png") }, --selene: allow(unused_variable) --selene: allow(unscoped_variables) -Smiles = playdate.graphics.image.new("images/game/logos/Smiles.png"), +{ name = "Checkmarks", image = playdate.graphics.image.new("images/game/logos/Checkmarks.png") }, --selene: allow(unused_variable) --selene: allow(unscoped_variables) -FingerGuns = playdate.graphics.image.new("images/game/logos/FingerGuns.png"), +{ name = "Smiles", image = playdate.graphics.image.new("images/game/logos/Smiles.png") }, --selene: allow(unused_variable) --selene: allow(unscoped_variables) -Base = playdate.graphics.image.new("images/game/logos/Base.png"), +{ name = "FingerGuns", image = playdate.graphics.image.new("images/game/logos/FingerGuns.png") }, --selene: allow(unused_variable) --selene: allow(unscoped_variables) -Frown = playdate.graphics.image.new("images/game/logos/Frown.png"), +{ name = "Frown", image = 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"), +{ name = "Arrows", image = 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"), +{ name = "Turds", image = playdate.graphics.image.new("images/game/logos/Turds.png") }, } diff --git a/src/assets.lua2p b/src/assets.lua2p index 431b745..5658d39 100644 --- a/src/assets.lua2p +++ b/src/assets.lua2p @@ -1,5 +1,8 @@ -!(function dirLookup(dir, extension, newFunc, sep) +!(function dirLookup(dir, extension, newFunc, sep, handle) 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. local p = io.popen('find src/' .. dir .. ' -type f -maxdepth 1') @@ -11,7 +14,7 @@ file = file:gsub("src/", "") assetCode = assetCode .. '--selene: allow(unused_variable)\n' assetCode = assetCode .. '--selene: allow(unscoped_variables)\n' - assetCode = assetCode .. varName .. ' = ' .. newFunc .. '("' .. file .. '")' .. sep + assetCode = assetCode .. handle(varName, newFunc .. '("' .. file .. '")') .. sep end end return assetCode @@ -27,5 +30,11 @@ end)!!(generatedFileWarning()) --selene: allow(unused_variable) --selene: allow(unscoped_variables) Logos = { -!!(dirLookup('images/game/logos', 'png', 'playdate.graphics.image.new', ",\n")) +--selene: allow(unused_variable) +--selene: allow(unscoped_variables) +{ 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)) } diff --git a/src/dbg.lua b/src/dbg.lua index 460f400..98c13b9 100644 --- a/src/dbg.lua +++ b/src/dbg.lua @@ -38,6 +38,16 @@ function dbg.loadTheBases(br) br.runners[4].nextBase = C.Bases[C.Home] end +-- selene: allow(unused_variable) +---@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 return dbg end diff --git a/src/draw/fielder.lua b/src/draw/fielder.lua index 39ff001..b9949ec 100644 --- a/src/draw/fielder.lua +++ b/src/draw/fielder.lua @@ -5,14 +5,14 @@ local GloveOffX, GloveOffY = GloveSizeX / 2, GloveSizeY / 2 ---@param fielderX number ---@param fielderY number ---@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 shoulderX, shoulderY = fielderX + 10, fielderY - 5 if distanceFromBall > 20 then - Glove:draw(shoulderX, shoulderY) + Glove:draw(shoulderX, shoulderY, flip) return false else - GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY) + GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY, flip) return true end end @@ -22,7 +22,7 @@ end ---@param x number ---@param y number ---@return boolean isHoldingBall -function drawFielder(playerSprites, ball, x, y) - playerSprites.smiling:draw(x, y - 20) +function drawFielder(playerSprites, ball, x, y, flip) + playerSprites.smiling:draw(x, y - 20, flip) return drawFielderGlove(ball, x, y) end diff --git a/src/draw/player.lua b/src/draw/player.lua index bde0fdc..6426081 100644 --- a/src/draw/player.lua +++ b/src/draw/player.lua @@ -15,6 +15,7 @@ function maybeDrawInverted(image, x, y, drawInverted) gfx.setImageDrawMode(drawMode) end +--- TODO: Custom names on jerseys? ---@return SpriteCollection ---@param base pd_image ---@param isDark boolean @@ -52,7 +53,20 @@ function buildCollection(base, back, logo, isDark) end --selene: allow(unscoped_variables) -AwayTeamSprites = buildCollection(DarkPlayerBase, DarkPlayerBack, Logos.Base, true) +---@type SpriteCollection +AwayTeamSprites = nil --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) diff --git a/src/fielding.lua b/src/fielding.lua index 30258fd..f682213 100644 --- a/src/fielding.lua +++ b/src/fielding.lua @@ -153,19 +153,22 @@ end ---@param targetBase Base ---@param launchBall LaunchBall ---@param throwFlyMs number ----@return ActionResult -local function userThrowToImpl(field, targetBase, launchBall, throwFlyMs) - if field.fielderHoldingBall == nil then - return ActionResult.NeedsMoreTime - end - 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 - end) +local function userThrowToCoroutine(field, targetBase, launchBall, throwFlyMs) + while true do + if field.fielderHoldingBall == nil then + coroutine.yield() + else + 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 + end) - closestFielder.target = targetBase - launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) - Fielding.markIneligible(field.fielderHoldingBall) - return ActionResult.Succeeded + closestFielder.target = targetBase + launchBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs) + Fielding.markIneligible(field.fielderHoldingBall) + + return + end + end end --- Buffer in a fielder throw action. @@ -176,7 +179,7 @@ end function Fielding:userThrowTo(targetBase, launchBall, throwFlyMs) local maxTryTimeMs = 5000 actionQueue:upsert("userThrowTo", maxTryTimeMs, function() - return userThrowToImpl(self, targetBase, launchBall, throwFlyMs) + userThrowToCoroutine(self, targetBase, launchBall, throwFlyMs) end) end diff --git a/src/images/game/GameLogo.png b/src/images/game/GameLogo.png new file mode 100644 index 0000000..3c231b8 Binary files /dev/null and b/src/images/game/GameLogo.png differ diff --git a/src/main-menu.lua b/src/main-menu.lua new file mode 100644 index 0000000..1bfa887 --- /dev/null +++ b/src/main-menu.lua @@ -0,0 +1,123 @@ +-- selene: allow(unscoped_variables) +---@class MainMenu +MainMenu = { + mainGameUpdateFunction = nil, + mainGameInitFunction = nil, +} +-- selene: allow(shadowing) +local gfx = playdate.graphics + +-- selene: allow(shadowing) +local StartFont = gfx.font.new("fonts/Roobert-20-Medium.pft") + +--- Take control of playdate.update +---@param config MainMenu Function that controls the main gameplay loop. +--- Will replace playdate.update when the menu is done. +function MainMenu.start(config) + MainMenu.mainGameUpdateFunction = config.mainGameUpdateFunction + MainMenu.mainGameInitFunction = config.mainGameInitFunction + playdate.update = MainMenu.update +end + +local function startGame() + MainMenu.mainGameInitFunction() + playdate.update = MainMenu.mainGameUpdateFunction +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 = nil + +local inningCountSelection = 3 + +function playdate.upButtonDown() + inningCountSelection = inningCountSelection + 1 +end + +function playdate.downButtonDown() + inningCountSelection = math.max(1, inningCountSelection - 1) +end + +local itr = 0 + +local t = playdate.timer.new(1000) +t:reset() +function t.updateCallback() + itr = itr + 1 +end + +function MainMenu.update() + playdate.timer.updateTimers() + crankStartPos = crankStartPos or playdate.getCrankPosition() + + if playdate.getCrankChange() ~= 0 then + local crankOffset = (crankStartPos - playdate.getCrankPosition()) % 360 + currentLogo = arrayElementFromCrank(Logos, crankOffset).image + replaceAwayLogo(currentLogo) + end + + gfx.clear() + if playdate.buttonIsPressed(playdate.kButtonA) then + startGame() + end + + gfx.drawText(tostring(itr), 200, 120) + 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 diff --git a/src/main.lua b/src/main.lua index b42fc8a..02ceb42 100644 --- a/src/main.lua +++ b/src/main.lua @@ -27,6 +27,8 @@ import 'draw/player.lua' import 'draw/overlay.lua' import 'draw/fielder.lua' +import 'main-menu.lua' + import 'action-queue.lua' import 'announcer.lua' import 'ball.lua' @@ -37,6 +39,8 @@ import 'graphics.lua' import 'npc.lua' -- stylua: ignore end +-- TODO: Customizable field structure. E.g. stands and ads etc. + -- selene: allow(shadowing) local gfx , C = playdate.graphics, C @@ -82,9 +86,9 @@ local secondsSincePitchAllowed = 0 -- 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. -local runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper -local battingTeamSprites = AwayTeamSprites -local fieldingTeamSprites = HomeTeamSprites +local runnerBlipper +local battingTeamSprites +local fieldingTeamSprites ------------------------- -- END OF GLOBAL STATE -- @@ -455,7 +459,7 @@ end -- TODO: Swappable update() for main menu, etc. -function playdate.update() +function mainGameUpdate() playdate.timer.updateTimers() gfx.animation.blinker.updateAll() updateGameState() @@ -517,13 +521,8 @@ function playdate.update() end end -local function init() - playdate.display.setRefreshRate(50) - gfx.setBackgroundColor(gfx.kColorWhite) - playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) +local function mainGameInit() fielding:resetFielderPositions(teams.home.benchPosition) - playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? - playdate.timer.new(2000, function() launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, false) end) @@ -531,6 +530,27 @@ local function init() BootTune:setFinishCallback(function() TinnyBackground:play() end) + battingTeamSprites = AwayTeamSprites + fieldingTeamSprites = HomeTeamSprites + HomeTeamBlipper = blipper.new(100, HomeTeamSprites.smiling, HomeTeamSprites.lowHat) + AwayTeamBlipper = blipper.new(100, AwayTeamSprites.smiling, AwayTeamSprites.lowHat) + runnerBlipper = battingTeam == teams.away and AwayTeamBlipper or HomeTeamBlipper +end + +local function init() + playdate.display.setRefreshRate(50) + gfx.setBackgroundColor(gfx.kColorWhite) + playdate.setMenuImage(gfx.image.new("images/game/menu-image.png")) + playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO? + + -- TODO: A lot of stuff ends up hinky here, because animators are ticking from the moment they initialize. + -- TODO: Much needs to be redesigned to only init when a game is *actually* starting. + -- MainMenu.start({ + -- mainGameUpdateFunction = mainGameUpdate, + -- mainGameInitFunction = mainGameInit, + -- }) + playdate.update = mainGameUpdate + mainGameInit() end init() diff --git a/src/utils.lua b/src/utils.lua index 8357bb6..391fe75 100644 --- a/src/utils.lua +++ b/src/utils.lua @@ -164,6 +164,22 @@ function utils.pointDirectlyUnderLine(pointX, pointY, lineX1, lineY1, lineX2, li return utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2) 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. ---@return boolean function utils.pointUnderLine(pointX, pointY, lineX1, lineY1, lineX2, lineY2)