From aceefeb25c14d00fd88dbc2c212e91f9a557223f Mon Sep 17 00:00:00 2001 From: Sage Vaillancourt Date: Sun, 23 Feb 2025 13:10:09 -0500 Subject: [PATCH] Add a screen for showing the game's controls Tweak MainMenu appearance to show this new option. Simple new drawButton() graphics function. Set a max value for transition delta, to keep from leaving gaps in the mask. --- __stub.ext.lua | 3 +- src/assets.lua | 4 ++ src/control-screen.lua | 99 +++++++++++++++++++++++++++++ src/draw/transitions.lua | 27 +++++--- src/graphics.lua | 21 +++++- src/images/game/BallBackground.png | Bin 0 -> 10394 bytes src/main-menu.lua | 44 ++++++++++--- src/test/mocks.lua | 12 ++++ 8 files changed, 187 insertions(+), 23 deletions(-) create mode 100644 src/control-screen.lua create mode 100644 src/images/game/BallBackground.png diff --git a/__stub.ext.lua b/__stub.ext.lua index 1f41e90..4573b23 100644 --- a/__stub.ext.lua +++ b/__stub.ext.lua @@ -2,8 +2,7 @@ -- These warning-allieviators could also be injected directly into __types.lua -- Base __types.lua can be found at https://github.com/balpha/playdate-types --- selene: allow(unused_variable) --- selene: allow(unscoped_variables) +---@type pd_playdate_lib playdate = playdate -- selene: allow(unscoped_variables) diff --git a/src/assets.lua b/src/assets.lua index 57d659f..f8f4e15 100644 --- a/src/assets.lua +++ b/src/assets.lua @@ -1,6 +1,10 @@ -- GENERATED FILE - DO NOT EDIT -- Instead, edit the source file directly: assets.lua2p. +-- luacheck: ignore +---@type pd_image +BallBackground = playdate.graphics.image.new("images/game/BallBackground.png") + -- luacheck: ignore ---@type pd_image BigBat = playdate.graphics.image.new("images/game/BigBat.png") diff --git a/src/control-screen.lua b/src/control-screen.lua new file mode 100644 index 0000000..6aa0fb6 --- /dev/null +++ b/src/control-screen.lua @@ -0,0 +1,99 @@ +local gfx = playdate.graphics + +local HeaderFont = playdate.graphics.font.new("fonts/Roobert-11-Medium.pft") +local DetailFont = playdate.graphics.font.new("fonts/font-full-circle.pft") + +---@alias TextObject { text: string, font: pd_font } + +---@param texts TextObject[] +local function drawTexts(texts) + local xOffset = 10 + local initialOffset = -(HeaderFont:getHeight()) / 2 + local yOffset = initialOffset + + --- The text height plus a margin scaled to that height + function getOffsetOffset(textObject) + return (-4 + math.floor(textObject.font:getHeight() * 1.6)) / 2 + end + + -- Inverted buffer around text to separate it from the background + for _, textObject in ipairs(texts) do + local offsetOffset = getOffsetOffset(textObject) + yOffset = yOffset + offsetOffset + gfx.setImageDrawMode(gfx.kDrawModeInverted) + for x = xOffset - 6, xOffset + 6 do + for y = yOffset - 6, yOffset + 6 do + textObject.font:drawText(textObject.text, x, y) + end + end + yOffset = yOffset + offsetOffset + end + + -- Drawing the actual text afterward (instead of inline) keeps the inverted buffer from drawing over it. + yOffset = initialOffset + gfx.setImageDrawMode(gfx.kDrawModeCopy) + for _, textObject in ipairs(texts) do + local offsetOffset = getOffsetOffset(textObject) + yOffset = yOffset + offsetOffset + textObject.font:drawText(textObject.text, xOffset, yOffset) + yOffset = yOffset + offsetOffset + end +end + +---@param text string +---@return TextObject +local function header(text) + return { text = text, font = HeaderFont } +end + +---@param text string +---@return TextObject +local function detail(text) + return { text = text, font = DetailFont } +end + +---@class +---@field sceneToReturnTo Scene +---@field private renderedImage pd_image Static image doesn't need to be constantly re-rendered. +ControlScreen = {} + +---@return pd_image +local function draw() + local image = gfx.image.new(C.Screen.W, C.Screen.H) + gfx.pushContext(image) + BallBackground:draw(0, 0) + drawTexts({ + header("Batting:"), + detail("Swing the crank to swing your bat"), + detail("But watch out! Some pitches are tricky!"), + + header("Pitching:"), + detail("Swing the crank to pitch the ball"), + detail("But be careful! Throw too hard and it might go wild!"), + detail("(shh: try holding A or B while you pitch)"), + + header("Fielding:"), + detail("To throw, hold a direction button and crank!"), + detail("Right throws to 1st, Up goes to 2nd, etc."), + }) + gfx.popContext() + return image +end + +---@param sceneToReturnTo Scene +function ControlScreen.new(sceneToReturnTo) + return setmetatable({ + sceneToReturnTo = sceneToReturnTo, + renderedImage = draw(), + }, { __index = ControlScreen }) +end + +function ControlScreen:update() + gfx.animation.blinker.updateAll() + gfx.clear() + self.renderedImage:draw(0, 0) + drawButton("B", 370, 210) + if playdate.buttonJustPressed(playdate.kButtonA) or playdate.buttonJustPressed(playdate.kButtonB) then + transitionBetween(self, self.sceneToReturnTo) + end +end diff --git a/src/draw/transitions.lua b/src/draw/transitions.lua index a7ca79e..3153997 100644 --- a/src/draw/transitions.lua +++ b/src/draw/transitions.lua @@ -33,7 +33,8 @@ local function update() while seamAngle > math.rad(90) do local deltaSeconds = playdate.getElapsedTime() playdate.resetElapsedTime() - seamAngle = seamAngle - (deltaSeconds * 3) + -- Setting a max value keeps from leaving unmasked areas + seamAngle = seamAngle - math.min(0.1, deltaSeconds * 3) local seamAngleDeg = math.floor(math.deg(seamAngle)) seamAngleDeg = seamAngleDeg - (seamAngleDeg % degStep) @@ -68,25 +69,31 @@ function transitionTo(nextScene) transitionBetween(previousScene, nextScene) end +---@param scene Scene +---@return pd_image +local function getSceneRender(scene) + local image = gfx.image.new(C.Screen.W, C.Screen.H) + gfx.pushContext(image) + scene:update() + gfx.popContext() + return image +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() + previousSceneImage = getSceneRender(previousScene) + nextSceneImage = getSceneRender(nextScene) previousSceneMask = gfx.image.new(C.Screen.W, C.Screen.H, gfx.kColorWhite) previousSceneImage:setMaskImage(previousSceneMask) Transitions.nextScene = nextScene Transitions.previousScene = previousScene + + -- Prevents bad transition calculations due to a long "delta" + playdate.resetElapsedTime() end diff --git a/src/graphics.lua b/src/graphics.lua index d776cf5..7b2130e 100644 --- a/src/graphics.lua +++ b/src/graphics.lua @@ -1,3 +1,7 @@ +local gfx = playdate.graphics + +local ButtonFont = gfx.font.new("fonts/font-full-circle.pft") + --- Assumes that background image is of size --- XXX --- XOX @@ -21,6 +25,21 @@ function getDrawOffset(ballX, ballY) return offsetX * 1.3, offsetY end +local buttonBlinker = gfx.animation.blinker.new(750, 500, true) +buttonBlinker:start() + +--- Requires calling `playdate.graphics.animation.blinker.updateAll()` during `update()` to blink correctly. +function drawButton(buttonLabel, x, y) + gfx.setColor(gfx.kColorWhite) + gfx.fillCircleAtPoint(x + 4, y + 7, 12) + gfx.setColor(gfx.kColorBlack) + if buttonBlinker.on then + gfx.setLineWidth(1) + gfx.drawCircleAtPoint(x + 4, y + 7, 10) + end + ButtonFont:drawText(buttonLabel, x, y) +end + ---@class Blipper ---@field draw fun(self: self, disableBlipping: boolean, x: number, y: number) blipper = {} @@ -28,7 +47,7 @@ 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, spriteCollection) - local blinker = playdate.graphics.animation.blinker.new(msInterval, msInterval, true) + local blinker = gfx.animation.blinker.new(msInterval, msInterval, true) blinker:start() return { blinker = blinker, diff --git a/src/images/game/BallBackground.png b/src/images/game/BallBackground.png new file mode 100644 index 0000000000000000000000000000000000000000..15845b5eb38c76357e0f8ae93748ffb951587f69 GIT binary patch literal 10394 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 = gfx.font.new("fonts/Roobert-11-Medium.pft") +local ScoreFont = playdate.graphics.font.new("fonts/font-full-circle.pft") local TinyFont = gfx.font.new("fonts/Nano Sans.pft") --- Take control of playdate.update @@ -14,11 +18,18 @@ local TinyFont = gfx.font.new("fonts/Nano Sans.pft") function MainMenu.start(next) MenuMusic:play(0) MainMenu.next = next - playdate.update = MainMenu.update + playdate.update = function() + MainMenu:update() + end end local inningCountSelection = 3 +function MainMenu:showControls() + local next = ControlScreen.new(self) + transitionBetween(MainMenu, next) +end + local function startGame() local next = MainMenu.next.new({ finalInning = inningCountSelection, @@ -54,7 +65,7 @@ local animatorY = gfx.animator.new(2000, 60, 200, pausingEaser(utils.easingHill) animatorY.repeatCount = -1 animatorY.reverses = true -local crankStartPos = nil +local crankStartPos ---@generic T ---@param array T[] @@ -69,6 +80,16 @@ local currentLogo --luacheck: ignore function MainMenu:update() + if playdate.buttonJustPressed(playdate.kButtonA) then + startGame() + return + end + + if playdate.buttonJustPressed(playdate.kButtonB) then + self:showControls() + return + end + playdate.timer.updateTimers() crankStartPos = crankStartPos or playdate.getCrankPosition() @@ -84,13 +105,10 @@ function MainMenu:update() currentLogo:drawScaled(20, C.Center.y + 40, 3) end - if playdate.buttonJustPressed(playdate.kButtonA) then - startGame() - end - if playdate.buttonJustPressed(playdate.kButtonUp) then + if playdate.buttonJustPressed(playdate.kButtonUp) or playdate.buttonJustPressed(playdate.kButtonRight) then inningCountSelection = math.min(99, inningCountSelection + 1) end - if playdate.buttonJustPressed(playdate.kButtonDown) then + if playdate.buttonJustPressed(playdate.kButtonDown) or playdate.buttonJustPressed(playdate.kButtonLeft) then inningCountSelection = math.max(1, inningCountSelection - 1) end @@ -98,8 +116,14 @@ function MainMenu:update() GameLogo:drawCentered(C.Center.x, logoCenter) TinyFont:drawTextAligned("a game by Sage", C.Center.x, logoCenter + 35, kTextAlignment.center) - StartFont:drawTextAligned("Press A to start!", C.Center.x, 180, kTextAlignment.center) - gfx.drawTextAligned("with " .. inningCountSelection .. " innings", C.Center.x, 210, kTextAlignment.center) + local promptOffsetX = 120 + ScoreFont:drawTextAligned( + "Press A to start with <" .. inningCountSelection .. "> innings", + C.Center.x - promptOffsetX, + 180, + kTextAlignment.left + ) + ScoreFont:drawTextAligned("Press B for controls", C.Center.x - promptOffsetX, 198, kTextAlignment.left) local ball = { x = animatorX:currentValue(), diff --git a/src/test/mocks.lua b/src/test/mocks.lua index 30b5ee1..1fca7ad 100644 --- a/src/test/mocks.lua +++ b/src/test/mocks.lua @@ -17,6 +17,18 @@ local mockPlaydate = { return utils.staticAnimator(0) end, }, + animation = { + blinker = { + new = function() + return { start = function() end } + end, + }, + }, + font = { + new = function() + return {} + end, + }, }, }