Repository: cadin/panels Branch: main Commit: 12aa510d41ff Files: 22 Total size: 155.2 KB Directory structure: gitextract_qc0hrzn3/ ├── .gitignore ├── LICENSE ├── Panels.lua ├── README.md ├── assets/ │ └── fonts/ │ └── Asheville-Narrow-14-Bold.fnt └── modules/ ├── Alert.lua ├── Audio.lua ├── ButtonIndicator.lua ├── ChoiceList.lua ├── Color.lua ├── Credits.lua ├── Effect.lua ├── Font.lua ├── Image.lua ├── Input.lua ├── Layer.lua ├── Menus.lua ├── Panel.lua ├── ScrollConstants.lua ├── Settings.lua ├── TextAlignment.lua └── Utils.lua ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store ================================================ FILE: LICENSE ================================================ Attribution 4.0 International ======================================================================= Creative Commons Corporation ("Creative Commons") is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an "as-is" basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. Using Creative Commons Public Licenses Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC- licensed material, or material used under an exception or limitation to copyright. More considerations for licensors: wiki.creativecommons.org/Considerations_for_licensors Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor's permission is not necessary for any reason--for example, because of any applicable exception or limitation to copyright--then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public: wiki.creativecommons.org/Considerations_for_licensees ======================================================================= Creative Commons Attribution 4.0 International Public License By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. Section 1 -- Definitions. a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. Section 2 -- Scope. a. License grant. 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: a. reproduce and Share the Licensed Material, in whole or in part; and b. produce, reproduce, and Share Adapted Material. 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 3. Term. The term of this Public License is specified in Section 6(a). 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a) (4) never produces Adapted Material. 5. Downstream recipients. a. Offer from the Licensor -- Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. b. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). b. Other rights. 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 2. Patent and trademark rights are not licensed under this Public License. 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. Section 3 -- License Conditions. Your exercise of the Licensed Rights is expressly made subject to the following conditions. a. Attribution. 1. If You Share the Licensed Material (including in modified form), You must: a. retain the following if it is supplied by the Licensor with the Licensed Material: i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); ii. a copyright notice; iii. a notice that refers to this Public License; iv. a notice that refers to the disclaimer of warranties; v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; b. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and c. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. Section 4 -- Sui Generis Database Rights. Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. Section 5 -- Disclaimer of Warranties and Limitation of Liability. a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. Section 6 -- Term and Termination. a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. Section 7 -- Other Terms and Conditions. a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. Section 8 -- Interpretation. a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. ======================================================================= Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” The text of the Creative Commons public licenses is dedicated to the public domain under the CC0 Public Domain Dedication. Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark "Creative Commons" or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. Creative Commons may be contacted at creativecommons.org. ================================================ FILE: Panels.lua ================================================ -- Panels version 2.2 -- https://cadin.github.io/panels/ import "CoreLibs/object" import "CoreLibs/graphics" import "CoreLibs/sprites" import "CoreLibs/timer" import "CoreLibs/animation" import "CoreLibs/crank" local gfx = playdate.graphics local ScreenHeight = playdate.display.getHeight() local ScreenWidth = playdate.display.getWidth() Panels = {} Panels.comicData = {} Panels.credits = {} Panels.vars = {} Panels.persistentVars = {} Panels.percentageComplete = 0 Panels.unlockedSequences = {} Panels.visitedSequences = {} import "./modules/Font" import "./modules/Audio" import "./modules/Settings" import "./modules/ScrollConstants" import "./modules/ButtonIndicator" import "./modules/Color" import "./modules/Effect" import "./modules/Input" import "./modules/Image" import "./modules/Menus" import "./modules/Alert" import "./modules/Panel" import "./modules/Layer" import "./modules/ChoiceList" import "./modules/TextAlignment" import "./modules/Utils" import "./modules/Credits" -- PD function shortcuts= local pdUpdateTimers = playdate.timer.updateTimers local pdEaseInOutQuad = playdate.easingFunctions.inOutQuad local pdButtonJustPressed = playdate.buttonJustPressed local sequenceDidStart = false local sequenceIsFinishing = false local currentSeqIndex = 1 local sequences = nil local sequence = {} local panels = {} local scrollPos = 0 local scrollAcceleration = 0.25 local maxScrollVelocity = 8 local scrollVelocity = 0 local maxScroll = 0 local snapStrength = 1.5 local panelBoundaries = {} local transitionOutAnimator = nil local transitionInAnimator = nil local buttonIndicator = nil local numMenusOpen = 0 local numMenusFullScreen = 0 local menusAreFullScreen = false local chapterDidSelect = false local panelTransitionAnimator = nil local previousBGColor = nil local transitionFader = nil local shouldFadeBG = false local gameDidFinish = false local numSequencesUnlocked = 0 local alert = nil local isCutscene = false local cutsceneFinishCallback = nil local targetSequence = nil local mainCanvas = gfx.image.new(ScreenWidth, ScreenHeight, gfx.kColorBlack) local function setUpPanels(seq) panels = {} local pos = 0 local j = 1 if seq.panels == nil then printError(seq.title or "Untitled sequence", "No panel data found in sequence:") end local list = table.shallowcopy(seq.panels) if seq.scrollingIsReversed then reverseTable(list) end for i, panel in ipairs(list) do if panel.frame == nil then panel.frame = table.shallowcopy(seq.defaultFrame) end panel.axis = seq.axis panel.scrollingIsReversed = seq.scrollingIsReversed or false panel.direction = seq.direction if panel.advanceControl == nil then if panel.advanceControlSequence and #panel.advanceControlSequence >= 1 then panel.advanceControl = panel.advanceControlSequence[1] else panel.advanceControl = sequence.advanceControl end end if panel.advanceControlSequence == nil then panel.advanceControlSequence = { panel.advanceControl } end if panel.backControl == nil then panel.backControl = sequence.backControl end if panel.preventBacktracking == nil then panel.preventBacktracking = sequence.preventBacktracking or false end if sequence.font and panel.font == nil then panel.font = sequence.font end if sequence.fontFamily and panel.fontFamily == nil then panel.fontFamily = sequence.fontFamily end local p = Panels.Panel.new(panel) if panel.choiceList then p.onChoiceListSelectionChange = function(index, button) -- when the choice list selection changes, we can run a callback -- to update any panel state based on the selected choice if button.var then Panels.vars[button.var.key] = button.var.value end if button.target then sequence.nextSequence = button.target end if panel.choiceList.onSelectionChangeUserCallback then panel.choiceList.onSelectionChangeUserCallback(index, button) end end end if p.frame.margin then pos = pos + p.frame.margin end if p.frame.gap and i > 1 then pos = pos + p.frame.gap end if seq.axis == Panels.ScrollAxis.VERTICAL then p.frame.y = pos pos = pos + p.frame.height maxScroll = pos - ScreenHeight + p.frame.margin if i > 1 then panelBoundaries[j] = -(p.frame.y - p.frame.margin) j = j + 1 end if p.frame.height > ScreenHeight then panelBoundaries[j] = -(p.frame.y + p.frame.height - ScreenHeight + p.frame.margin) j = j + 1 end else p.frame.x = pos pos = pos + p.frame.width maxScroll = pos - ScreenWidth + p.frame.margin if i > 1 then panelBoundaries[j] = -(p.frame.x - p.frame.margin) j = j + 1 end if p.frame.width > ScreenWidth then panelBoundaries[j] = -(p.frame.x + p.frame.width - ScreenWidth + p.frame.margin) j = j + 1 end end panels[i] = p end end local function lastPanelIsShowing() local threshold = 24 if (sequence.scrollingIsReversed and scrollPos >= -threshold) or (not sequence.scrollingIsReversed and scrollPos <= -(maxScroll - threshold)) then return true end return false end -- ------------------------------------------------- -- BUTTON INDICATOR local function createButtonIndicators() buttonIndicators = {} if sequence.advanceControls == nil then buttonIndicators = { Panels.ButtonIndicator.new(sequence.advanceControlSize) } else for i, value in ipairs(sequence.advanceControls) do buttonIndicators[i] = Panels.ButtonIndicator.new(sequence.advanceControlSize) end end end local function drawButtonIndicators(offset) if transitionOutAnimator == nil then if lastPanelIsShowing() and sequenceDidStart and not sequenceIsFinishing then for key, button in pairs(buttonIndicators) do button:show() end else for key, button in pairs(buttonIndicators) do button:hide() end end end if sequence.showAdvanceControls and sequenceDidStart then for i, button in ipairs(buttonIndicators) do if sequence.advanceControls[i].anchor then local lastPanel = panels[#panels] button:draw(button.x + lastPanel.frame.x + offset.x , button.y + lastPanel.frame.y + offset.y) else button:draw() end end end end local function getAdvanceControlForScrollDirection(dir) if dir == Panels.ScrollDirection.LEFT_TO_RIGHT then return Panels.Input.RIGHT elseif dir == Panels.ScrollDirection.TOP_DOWN then return Panels.Input.DOWN elseif dir == Panels.ScrollDirection.BOTTOM_UP then return Panels.Input.UP elseif dir == Panels.ScrollDirection.NONE then return nil else return Panels.Input.LEFT end end local function getBackControlForScrollDirection(dir) if dir == Panels.ScrollDirection.LEFT_TO_RIGHT then return Panels.Input.LEFT elseif dir == Panels.ScrollDirection.TOP_DOWN then return Panels.Input.UP elseif dir == Panels.ScrollDirection.BOTTOM_UP then return Panels.Input.DOWN elseif dir == Panels.ScrollDirection.NONE then return nil else return Panels.Input.RIGHT end end -- ------------------------------------------------- -- SCROLLING local function prepareScrolling(reversed) if reversed then panelNum = #panels scrollPos = -maxScroll else scrollPos = 0 panelNum = 1 end end local function snapScrollToPanel() for i, b in ipairs(panelBoundaries) do if scrollPos > b - 20 and scrollPos < b + 20 then local diff = scrollPos - b scrollPos = round(scrollPos - (diff - (diff / 1.25)), 2) end end end local function updateScroll() if panelTransitionAnimator then scrollPos = panelTransitionAnimator:currentValue() if panelTransitionAnimator:ended() then panelTransitionAnimator = nil panels[panelNum]:enableInput(true) end else if scrollPos > 0 then scrollPos = math.floor(scrollPos / snapStrength) elseif scrollPos < -maxScroll then local diff = scrollPos + maxScroll scrollPos = math.floor(scrollPos - (diff - (diff / snapStrength))) end if Panels.Settings.snapToPanels then snapScrollToPanel() end end end -- ------------------------------------------------- -- PANEL TRANSITIONS local function isLastPanel(num) if (num == #panels and not sequence.scrollingIsReversed) or (sequence.scrollingIsReversed and num <= 1) then return true else return false end end local function isFirstPanel(num) if (num == 1 and not sequence.scrollingIsReversed) or (sequence.scrollingIsReversed and num == #panels) then return true else return false end end function getPanelScrollLocation(panel, isTrailingEdge) if sequence.axis == Panels.ScrollAxis.VERTICAL then if isTrailingEdge == true then return (panel.frame.y + panel.frame.margin + panel.frame.height - ScreenHeight) * -1 else return (panel.frame.y - panel.frame.margin) * -1 end else if isTrailingEdge == true then return (panel.frame.x + panel.frame.margin + panel.frame.width - ScreenWidth) * -1 else return (panel.frame.x - panel.frame.margin) * -1 end end end local function scrollToNextPanel() if not isLastPanel(panelNum) then if not sequence.rapidAdvance and panelTransitionAnimator and panelTransitionAnimator:progress() < 1 then print("aborting scroll to next panel, transition in progress") return end local p = panels[panelNum] p:enableInput(false) p.buttonsPressed = {} local target = 0 if p.frame.height > ScreenHeight and scrollPos > p.frame.y * -1 then target = getPanelScrollLocation(p, true) elseif p.frame.width > ScreenWidth and scrollPos > p.frame.x * -1 then target = getPanelScrollLocation(p, true) else if sequence.scrollingIsReversed then panelNum = panelNum - 1 else panelNum = panelNum + 1 end target = getPanelScrollLocation(panels[panelNum]) end if sequence.direction == Panels.ScrollDirection.NONE then scrollPos = target panels[panelNum]:enableInput(true) else print("starting panel transition") local duration = sequence.transitionDuration or 500 local ease = sequence.transitionEase or pdEaseInOutQuad panelTransitionAnimator = gfx.animator.new(duration, scrollPos, target, ease) end end end local function scrollToPreviousPanel() if not isFirstPanel(panelNum) then local p = panels[panelNum] p:enableInput(false) local target = 0 if p.frame.height > ScreenHeight and scrollPos < p.frame.y * -1 then target = getPanelScrollLocation(p) elseif p.frame.width > ScreenWidth and scrollPos < p.frame.x * -1 then target = getPanelScrollLocation(p) else if sequence.scrollingIsReversed then panelNum = panelNum + 1 else panelNum = panelNum - 1 end target = getPanelScrollLocation(panels[panelNum], true) end local duration = sequence.transitionDuration or 500 local ease = sequence.transitionEase or pdEaseInOutQuad panelTransitionAnimator = gfx.animator.new(duration, scrollPos, target, ease) end end -- ------------------------------------------------- -- SEQUENCE TRANSITIONS local function startTransitionIn(direction, delay, duration, ease) local target = scrollPos local start if direction == Panels.ScrollDirection.BOTTOM_UP then start = scrollPos - ScreenHeight elseif direction == Panels.ScrollDirection.TOP_DOWN then start = scrollPos + ScreenHeight elseif direction == Panels.ScrollDirection.LEFT_TO_RIGHT then start = scrollPos + ScreenWidth elseif direction == Panels.ScrollDirection.NONE then start = scrollPos else start = scrollPos - ScreenWidth end scrollPos = start -- make a dummy animator to hold scroll pos until delayed transition starts transitionInAnimator = playdate.graphics.animator.new(math.max(delay * 2, 2000), start, start) if previousBGColor then gfx.lockFocus(transitionFader) gfx.setColor(previousBGColor) gfx.fillRect(0, 0, ScreenWidth, ScreenHeight) gfx.unlockFocus() end shouldFadeBG = previousBGColor ~= nil and previousBGColor ~= sequence.backgroundColor local function delayedStart() duration = duration or Panels.Settings.sequenceTransitionDuration ease = ease or playdate.easingFunctions.inOutQuart transitionInAnimator = playdate.graphics.animator.new(duration, start, target, ease) end playdate.timer.performAfterDelay(delay, delayedStart) end local function startTransitionOut(direction, duration, ease) local target local start = scrollPos local duration = duration or Panels.Settings.sequenceTransitionDuration if direction == Panels.ScrollDirection.TOP_DOWN then target = -maxScroll - ScreenHeight elseif direction == Panels.ScrollDirection.BOTTOM_UP then target = maxScroll + ScreenHeight elseif direction == Panels.ScrollDirection.RIGHT_TO_LEFT then target = maxScroll + ScreenWidth elseif direction == Panels.ScrollDirection.NONE then target = scrollPos duration = 200 else target = -maxScroll - ScreenWidth end ease = ease or playdate.easingFunctions.inOutQuart transitionOutAnimator = playdate.graphics.animator.new(duration, start, target, ease) end local function getAxisForScrollDirection(dir) if dir == Panels.ScrollDirection.TOP_TO_BOTTOM or dir == Panels.ScrollDirection.BOTTOM_UP then return Panels.ScrollAxis.VERTICAL else return Panels.ScrollAxis.HORIZONTAL end end -- ------------------------------------------------- -- SEQUENCE LIFECYCLE local function setSequenceScrollDirection() if sequence.axis == nil and sequence.direction == nil then sequence.axis = Panels.ScrollAxis.HORIZONTAL end if sequence.axis == nil then sequence.axis = getAxisForScrollDirection(sequence.direction) end if sequence.direction == nil then if sequence.axis == Panels.ScrollAxis.VERTICAL then sequence.direction = Panels.ScrollDirection.TOP_DOWN else sequence.direction = Panels.ScrollDirection.LEFT_TO_RIGHT end elseif sequence.direction == Panels.ScrollDirection.NONE then sequence.axis = Panels.ScrollAxis.HORIZONTAL elseif sequence.direction == Panels.ScrollDirection.RIGHT_TO_LEFT or sequence.direction == Panels.ScrollDirection.BOTTOM_UP then sequence.scrollingIsReversed = true end end local function setSequenceColors() if sequence.backgroundColor == nil then if sequence.foregroundColor then sequence.backgroundColor = Panels.Color.invert(sequence.foregroundColor) else sequence.foregroundColor = Panels.Color.BLACK sequence.backgroundColor = Panels.Color.WHITE end else if sequence.foregroundColor == nil then sequence.foregroundColor = Panels.Color.invert(sequence.backgroundColor) end end end local function countVisitedSequences(unlocked) local count = 0 for i, v in ipairs(unlocked) do if v then count = count + 1 end end return count end local function calculatePercentageComplete() numSequencesVisited = countVisitedSequences(Panels.visitedSequences) Panels.percentageComplete = math.floor((numSequencesVisited / #sequences) * 100) -- print("This comic has " .. #sequences .. " sequences.") end local function markSequenceAsVisited(num) for i = 1, num, 1 do if not Panels.visitedSequences[i] then Panels.visitedSequences[i] = false end end Panels.visitedSequences[num] = true calculatePercentageComplete() end local function unlockSequence(num) for i = 1, num, 1 do if not Panels.unlockedSequences[i] then Panels.unlockedSequences[i] = false end end Panels.unlockedSequences[num] = true markSequenceAsVisited(num) end local function loadSequence(num) currentSeqIndex = num sequence = sequences[num] createButtonIndicators() unlockSequence(num) -- set default scroll direction for each axis if not specified setSequenceScrollDirection() setSequenceColors() if sequence.scrollType == nil then sequence.scrollType = Panels.ScrollType.MANUAL end if sequence.defaultFrame == nil then sequence.defaultFrame = Panels.Settings.defaultFrame end if sequence.advanceControls == nil then local control if sequence.advanceControl == nil then local _input = getAdvanceControlForScrollDirection(sequence.direction) control = {input = _input} sequence.advanceControl = _input else control = {input = sequence.advanceControl} end if sequence.advanceControlPosition == nil then local x, y = Panels.ButtonIndicator.getPosititonForScrollDirection(sequence.direction, sequence.advanceControlSize) control.x = x control.y = y else control.x = sequence.advanceControlPosition.x control.y = sequence.advanceControlPosition.y end sequence.advanceControls = { control } end if sequence.showAdvanceControls == nil then if sequence.showAdvanceControl == nil then sequence.showAdvanceControls = true else sequence.showAdvanceControls = sequence.showAdvanceControl end end if sequence.backControl == nil then sequence.backControl = getBackControlForScrollDirection(sequence.direction) end if sequence.audio then if sequence.audio.continuePrevious and (Panels.Audio.fileIsPlaying(sequence.audio.file) or sequence.audio.file == nil) then -- only continue playing if the specified file is already playing -- or no file is specified else if sequence.audio.file then Panels.Audio.startBGAudio( Panels.Settings.audioFolder .. sequence.audio.file, sequence.audio.loop or false, sequence.audio.volume or 1 ) else Panels.Audio.fadeOutAndKill() end end else Panels.Audio.fadeOutAndKill() end setUpPanels(sequence) prepareScrolling(sequence.scrollingIsReversed) for i, control in ipairs(sequence.advanceControls) do buttonIndicators[i]:setButton(control.input) if sequence.showAdvanceControls and (control.x == nil or control.y == nil) then local err = sequence.title or "Untitled sequence (" .. num .. ")" printError(err, "Invalid position for advance control") end buttonIndicators[i]:setPosition(control.x or (i-1) * 40, control.y or 0) end if sequence.scrollType == Panels.ScrollType.MANUAL then for i, p in ipairs(panels) do p:enableInput(true) end end startTransitionIn(sequence.direction, sequence.delay or 0, sequence.transitionDuration, sequence.transitionEase) end local function unloadSequence() if(sequence.direction == Panels.ScrollDirection.NONE) then -- because the lack of scroll causes these not to get called local lastPanel = panels[#panels] if lastPanel.targetSequenceFunction then targetSequence = lastPanel.targetSequenceFunction() end lastPanel:reset() end for i, p in ipairs(panels) do p:killTypingEffects() if p.wasOnScreen then p:reset() end p.sfxPlayer = nil if p.layers then for j, l in ipairs(p.layers) do if l.timer then l.timer:remove() l.timer = nil end l.sfxPlayer = nil if l.animationLoop then l.animationLoop = nil end l.img = nil l.imgTable = nil l = nil end -- p.layers = nil end p = nil end panelTransitionAnimator = nil Panels.Image.clearCache() sequence.didFinish = false previousBGColor = sequence.backgroundColor if targetSequence == nil and sequence.nextSequence then targetSequence = sequence.nextSequence end end local function getIndexForTarget(target) -- if target is a number, return it if type(target) == "number" then return target end -- if target is a string, find the index of the sequence with that id if type(target) == "string" then for i, seq in ipairs(sequences) do if seq.id == target then return i end end end printError("The target sequence '".. target .."' could not be found.", "Invalid target sequence ID") end local function nextSequence() local isDeadEnd = sequence.endSequence or false unloadSequence() local targetIndex = targetSequence and getIndexForTarget(targetSequence) or nil if targetSequence then loadSequence(targetIndex) targetSequence = nil updateMenuData(sequences, gameDidFinish, currentSeqIndex > 1) elseif currentSeqIndex < #sequences and not isDeadEnd then currentSeqIndex = currentSeqIndex + 1 loadSequence(currentSeqIndex) updateMenuData(sequences, gameDidFinish, currentSeqIndex > 1) elseif isCutscene then playdate.inputHandlers.pop() gameDidFinish = true cutsceneFinishCallback(targetIndex) Panels.Audio.killBGAudio() previousBGColor = nil -- prevent future cross-fade attempt else gameDidFinish = true updateMenuData(sequences, gameDidFinish, currentSeqIndex > 1) menusAreFullScreen = true if Panels.Settings.resetVarsOnGameOver then Panels.vars = {} end Panels.Audio.killBGAudio() Panels.mainMenu:show() end end local function updateSequenceTransition() if transitionOutAnimator then scrollPos = transitionOutAnimator:currentValue() if transitionOutAnimator:ended() then transitionOutAnimator = nil sequenceDidStart = false playdate.timer.performAfterDelay(1, nextSequence) -- prevent flash before transition in end elseif transitionInAnimator then scrollPos = transitionInAnimator:currentValue() if transitionInAnimator:ended() then sequenceDidStart = true sequenceIsFinishing = false transitionInAnimator = nil shouldFadeBG = false panels[panelNum]:enableInput(true) end end end local function finishSequence() if not sequence.didFinish then sequence.didFinish = true startTransitionOut(sequence.direction, sequence.transitionDuration, sequence.transitionEase) end end -- ------------------------------------------------- -- INPUTS local function shouldGoBack(panel) local should = true if panel.preventBacktracking then if panel.frame.height > ScreenHeight and scrollPos < panel.frame.y * -1 or panel.frame.width > ScreenWidth and scrollPos < panel.frame.x * -1 then -- same frame, allow it should = true else should = false end end return should end function Panels.cranked(change, accChange) if sequence.scrollType == Panels.ScrollType.MANUAL or sequence.autoAdvanceWithCrank then if sequence.axis == Panels.ScrollAxis.VERTICAL and sequence.scrollingIsReversed then scrollPos = scrollPos + change else scrollPos = scrollPos - change end end if sequence.scrollType == Panels.ScrollType.AUTO and sequence.autoAdvanceWithCrank then local ticks = playdate.getCrankTicks(sequence.autoAdvanceTicks or 6) if ticks > 0 then scrollToNextPanel() elseif ticks < 0 then local p = panels[panelNum] if shouldGoBack(p) then scrollToPreviousPanel() end end end end local function hideOtherAdvanceControls(pressedIndex) for i, button in ipairs(buttonIndicators) do if i ~= pressedIndex then button:hide() end end end local function checkAdvanceControlSequence(panel, callback) local didTrigger = false local trigger = panel.advanceControlSequence[#panel.buttonsPressed + 1] if pdButtonJustPressed(trigger) and panel.inputEnabled then panel.buttonsPressed[#panel.buttonsPressed + 1] = trigger if #panel.buttonsPressed == #panel.advanceControlSequence then if panel.advanceDelay then panel:exit() playdate.timer.performAfterDelay(panel.advanceDelay, callback) else callback() end else playdate.timer.performAfterDelay(500, function () panel:nextAdvanceControl(#panel.buttonsPressed + 1, true) end ) end didTrigger = true end return didTrigger end local function checkInputs() local p = panels[panelNum] if sequenceIsFinishing then return end if lastPanelIsShowing() then p = panels[#panels] -- make sure we're dealing with the last panel if p.advanceFunction == nil then if #p.advanceControlSequence > 1 then local didTrigger = checkAdvanceControlSequence(p, finishSequence) if didTrigger then return end else for i, button in ipairs(buttonIndicators) do if pdButtonJustPressed(sequence.advanceControls[i].input) then if sequence.advanceControls[i].target then targetSequence = sequence.advanceControls[i].target end button:press() hideOtherAdvanceControls(i) sequenceIsFinishing = true if p.advanceDelay then p:exit() playdate.timer.performAfterDelay(p.advanceDelay, finishSequence) else finishSequence() end end end end end end if sequence.scrollType == Panels.ScrollType.AUTO then if p.advanceFunction == nil then if p.advanceControlSequence then local didTrigger = checkAdvanceControlSequence(p, scrollToNextPanel) if didTrigger then return end else if pdButtonJustPressed(p.advanceControl) and p.inputEnabled then scrollToNextPanel() end end end if pdButtonJustPressed(p.backControl) then if shouldGoBack(p) then scrollToPreviousPanel() end end end end local function updateArrowControls() if (sequence.axis == Panels.ScrollAxis.VERTICAL and playdate.buttonIsPressed(Panels.Input.UP)) or (sequence.axis == Panels.ScrollAxis.HORIZONTAL and playdate.buttonIsPressed(Panels.Input.LEFT)) then scrollVelocity = scrollVelocity + scrollAcceleration elseif (sequence.axis == Panels.ScrollAxis.VERTICAL and playdate.buttonIsPressed(Panels.Input.DOWN)) or (sequence.axis == Panels.ScrollAxis.HORIZONTAL and playdate.buttonIsPressed(Panels.Input.RIGHT)) then scrollVelocity = scrollVelocity - scrollAcceleration else scrollVelocity = scrollVelocity / 2 end if scrollVelocity > maxScrollVelocity then scrollVelocity = maxScrollVelocity elseif scrollVelocity < -maxScrollVelocity then scrollVelocity = -maxScrollVelocity end scrollPos = scrollPos + scrollVelocity end -- ------------------------------------------------- -- GAME LOOP local function getScrollOffset() local offset = { x = 0, y = 0 } if sequence.axis == Panels.ScrollAxis.HORIZONTAL then offset.x = scrollPos else offset.y = scrollPos end return offset end local function updateComic(offset) if transitionInAnimator or transitionOutAnimator then updateSequenceTransition() else if panels and #panels < 1 then printError("`panels` table is empty", "This sequence has invalid panel definitions.") end if panels and panels[panelNum]:shouldAutoAdvance() then if not isLastPanel(panelNum) then print("auto advancing to next panel") scrollToNextPanel() else finishSequence() end end updateScroll() if sequence.scrollType == Panels.ScrollType.MANUAL then updateArrowControls() end checkInputs() end end local function drawComic(offset) gfx.pushContext(mainCanvas) gfx.clear(sequence.backgroundColor) if shouldFadeBG then local pct = 1 - (transitionInAnimator:currentValue() - transitionInAnimator.startValue) / (transitionInAnimator.endValue - transitionInAnimator.startValue) transitionFader:drawFaded(0, 0, pct, gfx.image.kDitherTypeBayer8x8) end for i, panel in ipairs(panels) do if panel:isOnScreen(offset) then if panel.wasOnScreen ~= true then panel:setup() end panel:render(offset, sequence.foregroundColor, sequence.backgroundColor) elseif panel.wasOnScreen then if panel.targetSequenceFunction then targetSequence = panel.targetSequenceFunction() end panel:reset() panel.wasOnScreen = false end end gfx.popContext() mainCanvas:draw(0, 0) if Panels.Settings.showFPS then playdate.drawFPS(0,0) end end -- Playdate update loop function Panels.update() if not menusAreFullScreen then local offset = getScrollOffset() updateComic(offset) drawComic(offset) drawButtonIndicators(offset) end if numMenusOpen > 0 then updateMenus() end if alert.isActive then alert:udpate() end pdUpdateTimers() end -- ------------------------------------------------- -- SAVE & LOAD GAME PROGRESS local function loadGameData() local data = playdate.datastore.read() if data then currentSeqIndex = data.sequence or 1 Panels.unlockedSequences = data.unlockedSequences or {} Panels.visitedSequences = data.visitedSequences or {} calculatePercentageComplete() gameDidFinish = data.gameDidFinish Panels.vars = data.vars or {} Panels.persistentVars = data.persistentVars or {} end end local function saveGameData() playdate.datastore.write({ sequence = currentSeqIndex, unlockedSequences = Panels.unlockedSequences, visitedSequences = Panels.visitedSequences, gameDidFinish = gameDidFinish, vars = Panels.vars, persistentVars = Panels.persistentVars }) end function playdate.gameWillTerminate() saveGameData() end function playdate.deviceWillSleep() saveGameData() end function playdate.deviceWillLock() saveGameData() end -- ------------------------------------------------- -- MENU HANDLERS function Panels.onChapterSelected(chapter) chapterDidSelect = true Panels.Audio.stopBGAudio() unloadSequence() currentSeqIndex = chapter loadSequence(currentSeqIndex) end function Panels.onMenuWillShow(menu) numMenusOpen = numMenusOpen + 1 Panels.Audio.pauseBGAudio() Panels.Audio.muteTypingSounds() if panels then for i, p in ipairs(panels) do if p.wasOnScreen then p:pauseSounds() end end end end function Panels.onMenuDidShow() menusAreFullScreen = true numMenusFullScreen = numMenusFullScreen + 1 end function Panels.onMenuWillHide(menu) if menu == Panels.mainMenu then if not chapterDidSelect then Panels.Audio.unmuteTypingSounds() loadSequence(currentSeqIndex) end end numMenusFullScreen = numMenusFullScreen - 1 if numMenusFullScreen < 1 then menusAreFullScreen = false end end function Panels.onMenuDidHide(menu) numMenusOpen = numMenusOpen - 1 if numMenusOpen < 1 then Panels.Audio.resumeBGAudio() Panels.Audio.unmuteTypingSounds() if panels then for i, p in ipairs(panels) do if p.wasOnScreen then p:unPauseSounds() end end end chapterDidSelect = false end end function Panels.onMenuDidStartOver() if not Panels.Settings.useChapterMenu and gameDidFinish then onAlertDidStartOver() else alert:show() end end function onAlertDidStartOver() Panels.Audio.stopBGAudio() Panels.unlockedSequences = {} gameDidFinish = false saveGameData() unloadSequence() currentSeqIndex = 1 Panels.vars = {} Panels.mainMenu:hide() createMenus(sequences, gameDidFinish, currentSeqIndex > 1) end function onAlertDidHide() if alert.selection == 2 then onAlertDidStartOver() end end function shouldShowMainMenu() local should = false if Panels.Settings.showMenuOnLaunch then if (currentSeqIndex and currentSeqIndex > 1) or Panels.Settings.skipMenuOnFirstLaunch == false then should = true end end if gameDidFinish then should = true end return should end -- ------------------------------------------------- -- START GAME local function updateSystemMenu() local sysMenu = playdate.getSystemMenu() if Panels.Settings.useChapterMenu then local chaptersMenuItem, error = sysMenu:addMenuItem("Chapters", function() Panels.creditsMenu:hide() Panels.chapterMenu:show() end ) printError(error, "Error adding Chapters to system menu") end if Panels.Settings.showMainMenuOption then local homeMenuItem, error = sysMenu:addMenuItem(Panels.Settings.mainMenuOptionLabel or "Main Menu", function() Panels.creditsMenu:hide() if Panels.chapterMenu then Panels.chapterMenu:hide() end menusAreFullScreen = true Panels.mainMenu:show() end ) printError(error, "Error adding Main Menu to system menu") end if Panels.Settings.useCreditsMenu then local creditsItem, error2 = sysMenu:addMenuItem("Credits", function() if Panels.chapterMenu then Panels.chapterMenu:hide() end Panels.creditsMenu:show() end ) printError(error2, "Error adding Credits to system menu:") end end local function createCreditsSequence() local credits = Panels.Credits.new() local img = gfx.image.new(400, credits.height + 44) gfx.lockFocus(img) credits:redraw(0) gfx.unlockFocus() credits = nil local seq = { delay = 1000, transitionDuration = 1000, direction = Panels.ScrollDirection.TOP_DOWN, advanceControl = Panels.Input.A, panels = { { frame = { height = img.height, margin = 4 }, borderless = true, layers = { { img = img, y = 10 } } }, } } table.insert(Panels.comicData, seq) end function setDefaultFont() if Panels.Settings.defaultFontFamily then gfx.setFontFamily(Panels.Font.getFamily(Panels.Settings.defaultFontFamily)) elseif Panels.Settings.defaultFont then gfx.setFont(Panels.Font.get(Panels.Settings.defaultFont)) end end -- call this if you need to interrupt a cutscene (from a menu option for example) -- this should clean up panel and sequence audio that normally happens when the cutscene completes function Panels.haltCutscene() Panels.Audio.killBGAudio() unloadSequence() previousBGColor = nil -- prevent future cross-fade attempt playdate.inputHandlers.pop() end function Panels.startCutscene(comicData, callback) setDefaultFont() isCutscene = true cutsceneFinishCallback = callback Panels.comicData = comicData maxScrollVelocity = Panels.Settings.maxScrollSpeed alert = Panels.Alert.new("Start Over?", "All progress will be lost.", { "Cancel", "Start Over" }) alert.onHide = onAlertDidHide Panels.Audio.createTypingSound() validateSettings() sequences = Panels.comicData currentSeqIndex = 1 loadSequence(currentSeqIndex) playdate.inputHandlers.push({ cranked = Panels.cranked }) end function Panels.start(comicData) setDefaultFont() Panels.comicData = comicData maxScrollVelocity = Panels.Settings.maxScrollSpeed alert = Panels.Alert.new("Start Over?", "All progress will be lost.", { "Cancel", "Start Over" }) alert.onHide = onAlertDidHide Panels.Audio.createTypingSound() if Panels.Settings.showCreditsOnGameOver then createCreditsSequence() end transitionFader = gfx.image.new(ScreenWidth, ScreenHeight) sequences = Panels.comicData loadGameData() validateSettings() updateSystemMenu() createMenus(sequences, gameDidFinish, currentSeqIndex and currentSeqIndex > 1) if shouldShowMainMenu() then menusAreFullScreen = true Panels.mainMenu:show() else loadSequence(currentSeqIndex) end playdate.update = Panels.update playdate.cranked = Panels.cranked end -- ------------------------------------------------- -- DEBUG local function unlockAll() print("Levels unlocked. Restart game.") Panels.unlockedSequences = {} for i = 1, #sequences, 1 do table.insert(Panels.unlockedSequences, true) end gameDidFinish = true saveGameData() end function playdate.keyPressed(key) if key == "0" then if Panels.Settings.debugControlsEnabled then unlockAll() end end end ================================================ FILE: README.md ================================================ # Panels Build interactive comics for the Playdate console. ![Banner](./assets/images/panelsBanner.gif) Provide Panels with a Lua table that describes the sequences in your comic (scroll direction, panel sizes, text, animation and effects) along with your layered graphics. Panels will handle layout, scrolling, animation, and even chapter navigation for you. Comics built with Panels can support these features: - layered, parallax scrolling - nested panels - sequences with different scroll directions - manual (crank) scrolling and auto advancing (panel-by-panel) - panel effects like shake and blink - animated transitions between sequences - animations and transitions within panels based on scroll position - animated text layers - panels with fully custom render functions - branching "choose-your-own-adventure" storylines ## Documentation Check out the full set of documentation here: ### [📄 Panels Documentation](//cadin.github.io/panels) ### [📺 Tutorial Videos](https://www.youtube.com/playlist?list=PLvk_cJkKCihbN4Q61lopDtSQMbx4vNLvv) ## Requirements - [Playdate SDK](https://play.date/dev/) - [Playdate Console](https://shop.play.date) (optional) ## Setup ### From Template Project 1. Clone the [Panels Project Template](https://github.com/cadin/panels-project-template). This is a [Template Repo](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template). Click "Use this template" to create your own fresh repo with all the contents of the project template. 2. The template project includes the Panels framework as a git submodule. Be sure to properly [initialize the submodule](https://www.w3docs.com/snippets/git/how-to-clone-including-submodules.html) when cloning the repo. 3. Start editing table in `myComicData.lua`. ### Manual Setup 1. Clone this repo into your project into a `libraries` folder. 2. Inside your `main.lua` file import Panels. 3. Create or import your [`comicData`](http://cadin.github.io/panels/docs/comic-data) table. 4. Start Panels with your `comicData` table as the sole argument. ### Example `main.lua` File: ```lua import "libraries/panels/Panels" local comicData = { -- comic data goes here... } Panels.start(comicData) ``` ## Support ### Get Help - 📺 Watch these **[Tutorial Videos](https://www.youtube.com/playlist?list=PLvk_cJkKCihbN4Q61lopDtSQMbx4vNLvv)** to get up to speed quickly. - 🤖 Chat with the **[Panels Partner custom GPT](https://chat.openai.com/g/g-QU76MOCLl-panels-partner)** to get answers to questions about your specific project. - 💬 Post your question in the **[Playdate Squad Discord](https://discord.com/channels/675983554655551509/1163630567393341461)**. ### Feature Requests Add feature requests to the [Issues](https://github.com/cadin/panels/issues) page. Include a description of the general functionality you need, along with your preferred implementation (if you have one). Please search first to see if someone else has already created an issue for your feature. If so, you can add a vote or comment to show your support. ### Bug Reports File bug reports on the [Issues](https://github.com/cadin/panels/issues) page. Each bug should be listed as a separate issue. Please search first to see if someone else has already filed the bug, and list all steps needed to reproduce the issue in the smallest possible project. ### Contribute If you would like to contribute a feature or bug fix please contact me first and let me know which issue you want work on. If there isn't yet an issue for your proposed change, go ahead and write one. ## License Panels is licensed under a [Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/). **TLDR:** You can use this code (or modified versions) to create anything you want, public or private, free or commercial. For attribution, please retain the Panels credit (with URL and QR code) on the Credits page of your game so that others may find their way here. --- 👨🏻‍🦲❤️🛠 ================================================ FILE: assets/fonts/Asheville-Narrow-14-Bold.fnt ================================================ --metrics={"baseline":0,"xHeight":0,"capHeight":0,"left":["BDEFHIKLMNPRbhkl","GO","aceo","mnr"],"right":["DO","HIMNdl","JU","gy"],"pairs":{"Fa":[-1,1],"Fc":[-1,1],"Fe":[-1,1],"Fo":[-1,1],"Fm":[-1,1],"Fn":[-1,1],"Fr":[-1,1],"Ta":[-3,1],"Tc":[-3,1],"Te":[-3,1],"To":[-3,1],"Tm":[-3,1],"Tn":[-3,1],"Tr":[-3,1],"Dj":[-2,2],"Oj":[-2,2],"Hj":[-2,2],"Ij":[-2,2],"Mj":[-2,2],"Nj":[-2,2],"dj":[-2,2],"lj":[-2,2],"Jj":[-2,2],"Uj":[-2,2],"gT":[-3,2],"yT":[-3,2],"AT":[-2,0],"AV":[-1,0],"AW":[-1,0],"AY":[-1,0],"Af":[-1,0],"Aj":[-2,0],"At":[-1,0],"BT":[-1,0],"BV":[-1,0],"BW":[-1,0],"BY":[-1,0],"Bf":[-1,0],"Bj":[-2,0],"Bt":[-1,0],"Cj":[-2,0],"Ef":[-1,0],"Ej":[-2,0],"Et":[-1,0],"Ev":[-1,0],"FA":[-1,0],"FJ":[-4,0],"Fd":[-1,0],"Ff":[-1,0],"Fg":[-1,0],"Fj":[-2,0],"Fp":[-1,0],"Fq":[-1,0],"Fs":[-1,0],"Ft":[-1,0],"Fu":[-1,0],"Fv":[-1,0],"Fw":[-1,0],"Fx":[-1,0],"Fy":[-1,0],"Fz":[-1,0],"Gj":[-2,0],"Kf":[-1,0],"Kj":[-2,0],"Kt":[-1,0],"Kv":[-1,0],"LT":[-3,0],"LV":[-2,0],"LW":[-2,0],"LY":[-2,0],"Lf":[-1,0],"Lj":[-2,0],"Lt":[-1,0],"Lv":[-1,0],"PA":[-1,0],"PJ":[-5,0],"Pj":[-2,0],"Rj":[-2,0],"Sj":[-2,0],"TA":[-2,0],"TJ":[-3,0],"Td":[-3,0],"Tf":[-1,0],"Tg":[-3,0],"Tj":[-2,0],"Tp":[-3,0],"Tq":[-3,0],"Ts":[-3,0],"Tt":[-1,0],"Tu":[-3,0],"Tv":[-3,0],"Tw":[-3,0],"Tx":[-3,0],"Ty":[-3,0],"Tz":[-3,0],"VA":[-1,0],"VJ":[-2,0],"Vj":[-2,0],"WA":[-1,0],"WJ":[-1,0],"Wj":[-2,0],"Xf":[-1,0],"Xj":[-2,0],"Xt":[-1,0],"Xv":[-1,0],"YA":[-1,0],"YJ":[-2,0],"Yj":[-2,0],"Zj":[-2,0],"aT":[-3,0],"aj":[-2,0],"bT":[-3,0],"bj":[-2,0],"cT":[-3,0],"cj":[-2,0],"eT":[-3,0],"ej":[-2,0],"fA":[-1,0],"fJ":[-2,0],"fj":[-2,0],"hT":[-3,0],"hj":[-2,0],"ij":[-2,0],"kT":[-3,0],"kj":[-2,0],"mT":[-3,0],"mj":[-2,0],"nT":[-3,0],"nj":[-2,0],"oT":[-3,0],"oj":[-2,0],"pT":[-3,0],"pj":[-2,0],"qT":[-3,0],"rA":[-1,0],"rJ":[-3,0],"rT":[-3,0],"rX":[-1,0],"rZ":[-2,0],"rj":[-2,0],"sT":[-3,0],"sj":[-2,0],"tA":[-1,0],"tJ":[-1,0],"tT":[-1,0],"tX":[-1,0],"tZ":[-1,0],"tj":[-2,0],"uT":[-3,0],"uj":[-2,0],"vT":[-3,0],"vX":[-1,0],"vj":[-2,0],"wT":[-3,0],"wj":[-2,0],"xT":[-3,0],"xj":[-2,0],"zT":[-3,0],"zj":[-2,0]}} datalen=20956 data=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 width=20 height=20 tracking=1 0 8 1 3 2 7 3 7 4 7 5 9 6 8 7 7 8 8 9 8 space 3 ! 2 " 5 # 9 $ 8 % 12 & 11 ' 3 ( 5 ) 5 * 8 + 6 , 3 - 6 . 2 / 6 : 2 ; 2 < 6 = 6 > 6 ? 7 @ 10 A 8 B 8 C 7 D 8 E 6 F 6 G 8 H 7 I 2 J 7 K 8 L 6 M 11 N 8 O 8 P 8 Q 8 R 7 S 7 T 8 U 8 V 8 W 12 X 8 Y 6 Z 6 [ 3 \ 6 ] 3 ^ 6 _ 7 ` 3 a 7 b 7 c 7 d 7 e 7 f 5 g 7 h 7 i 2 j 4 k 7 l 2 m 10 n 7 o 7 p 7 q 7 r 5 s 6 t 4 u 7 v 6 w 10 x 7 y 7 z 6 { 5 | 2 } 5 ~ 8 … 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 � 13 Fa -1 Fc -1 Fe -1 Fo -1 Fm -1 Fn -1 Fr -1 Ta -3 Tc -3 Te -3 To -3 Tm -3 Tn -3 Tr -3 Dj -2 Oj -2 Hj -2 Ij -2 Mj -2 Nj -2 dj -2 lj -2 Jj -2 Uj -2 gT -3 yT -3 AT -2 AV -1 AW -1 AY -1 Af -1 Aj -2 At -1 BT -1 BV -1 BW -1 BY -1 Bf -1 Bj -2 Bt -1 Cj -2 Ef -1 Ej -2 Et -1 Ev -1 FA -1 FJ -4 Fd -1 Ff -1 Fg -1 Fj -2 Fp -1 Fq -1 Fs -1 Ft -1 Fu -1 Fv -1 Fw -1 Fx -1 Fy -1 Fz -1 Gj -2 Kf -1 Kj -2 Kt -1 Kv -1 LT -3 LV -2 LW -2 LY -2 Lf -1 Lj -2 Lt -1 Lv -1 PA -1 PJ -5 Pj -2 Rj -2 Sj -2 TA -2 TJ -3 Td -3 Tf -1 Tg -3 Tj -2 Tp -3 Tq -3 Ts -3 Tt -1 Tu -3 Tv -3 Tw -3 Tx -3 Ty -3 Tz -3 VA -1 VJ -2 Vj -2 WA -1 WJ -1 Wj -2 Xf -1 Xj -2 Xt -1 Xv -1 YA -1 YJ -2 Yj -2 Zj -2 aT -3 aj -2 bT -3 bj -2 cT -3 cj -2 eT -3 ej -2 fA -1 fJ -2 fj -2 hT -3 hj -2 ij -2 kT -3 kj -2 mT -3 mj -2 nT -3 nj -2 oT -3 oj -2 pT -3 pj -2 qT -3 rA -1 rJ -3 rT -3 rX -1 rZ -2 rj -2 sT -3 sj -2 tA -1 tJ -1 tT -1 tX -1 tZ -1 tj -2 uT -3 uj -2 vT -3 vX -1 vj -2 wT -3 wj -2 xT -3 xj -2 zT -3 zj -2 ================================================ FILE: modules/Alert.lua ================================================ local gfx = playdate.graphics local ScreenWidth = playdate.display.getWidth() local ScreenHeight = playdate.display.getHeight() Panels.Alert = {} local titleFont = gfx.getSystemFont("bold") local textFont = gfx.getSystemFont() local listFont = gfx.getSystemFont() local animator = nil local dimScreen = gfx.image.new(ScreenWidth, ScreenHeight, Panels.Color.BLACK) local gridView = nil local selectionSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/selection.wav") local selectionRevSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/selection-reverse.wav") local denialSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/denial.wav") local confirmSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/confirm.wav") local hideSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/swish-out.wav") local showSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/swish-in.wav") function Panels.Alert.new(title, text, options, callback, selection) local width = 320 local height = 150 local x = (ScreenWidth - width) / 2 local y = (ScreenHeight - height) / 2 - 8 local offset = 0 if Panels.Settings.menuFontFamily then local family = Panels.Font.getFamily(Panels.Settings.menuFontFamily) titleFont = family textFont = family listFont = family elseif Panels.Settings.defaultFontFamily then local family = Panels.Font.getFamily(Panels.Settings.defaultFontFamily) titleFont = family textFont = family listFont = family end gridView = playdate.ui.gridview.new((width - 32) / 2 - 8, 32) gridView:setNumberOfRows(1) gridView:setNumberOfColumns(#options) gridView:setCellPadding(4,4, 4, 4) gridView:setSelection(1, 1, selection or 1) local alert = { isActive = false, title = title, text = text, options = options, selection = selection or 1, state = "hidden" } function alert:getSelection() return self.selection end function gridView:drawCell(section, row, column, selected, x, y, width, height) gfx.pushContext() local text = alert.options[column] if selected then gfx.setColor(gfx.kColorBlack) gfx.fillRoundRect(x + offset, y, width, height, 4) gfx.setImageDrawMode(gfx.kDrawModeFillWhite) text = "*" .. text .. "*" offset = 0 else gfx.setImageDrawMode(gfx.kDrawModeCopy) end gfx.setFont(listFont) gfx.drawTextInRect(text, x, y+8, width, height+2, nil, "...", kTextAlignment.center) gfx.popContext() end function alert:drawBG(progress) local w = width * progress local h = height * progress local _x = x + (width - w) / 2 local _y = y + (height - h) / 2 gfx.pushContext() gfx.setColor(Panels.Color.WHITE) gfx.setLineWidth(6) gfx.drawRoundRect(_x, _y, w, h, 10) gfx.fillRoundRect(_x, _y, w, h, 8) gfx.setColor(Panels.Color.BLACK) gfx.setLineWidth(2) gfx.drawRoundRect(_x, _y, w, h, 8) gfx.popContext() end function alert:drawText() gfx.setFont(titleFont) gfx.drawTextInRect("*".. self.title .. "*", x + 16, y + 16, width - 32, 32, nil, "...", kTextAlignment.center) gfx.setFont(textFont) gfx.drawTextInRect(self.text, x + 16, y + 48, width - 32, height - 128, nil, "...", kTextAlignment.center) end function alert:hide() self.state = "hiding" animator = gfx.animator.new(200, 1, 0, playdate.easingFunctions.inOutQuad) playdate.inputHandlers.pop() if Panels.Settings.playMenuSounds then hideSound:play() end end function alert:show() self.state = "showing" local inputHandlers = { rightButtonUp = function() local s, r, column = gridView:getSelection() if Panels.Settings.playMenuSounds then if column == #self.options then denialSound:play() else selectionSound:play() end end offset = 4 gridView:selectNextColumn(false) end, leftButtonUp = function() local s, r, column = gridView:getSelection() if Panels.Settings.playMenuSounds then if column == 1 then denialSound:play() else selectionRevSound:play() end end offset = -4 gridView:selectPreviousColumn(false) end, AButtonDown = function() local s, r, column = gridView:getSelection() self.selection = column self:hide() if Panels.Settings.playMenuSounds then confirmSound:play() end end, BButtonDown = function() self.selection = 1 self:hide() end, } self.isActive = true animator = gfx.animator.new(250, 0, 1, playdate.easingFunctions.inOutQuad) playdate.inputHandlers.push(inputHandlers, true) if Panels.Settings.playMenuSounds then showSound:play() end end function alert:udpate() local progress = animator:currentValue() dimScreen:drawFaded(0, 0, 0.5 * progress, gfx.image.kDitherTypeBayer8x8) self:drawBG(progress) if progress >= 1 then self:drawText() gridView:drawInRect(x + 16, y + height - 42 - 8, width - 32, 42) self.state = "visible" end if self.state ~= "hidden" and progress <= 0 and animator:ended() then if self.onHide then self.state = "hidden" self:onHide() end end end return alert end ================================================ FILE: modules/Audio.lua ================================================ local bgAudioPlayer = nil local shouldResume = false local repeatCount = 1 local typingRetainCount = 0 local typingSamplePlayer local typingIsMuted = false local bgAudioFile = "" Panels.Audio = { TypingSound = { DEFAULT = "default", NONE = "none" } } function Panels.Audio.createTypingSound() local path = Panels.Settings.path .. "assets/audio/typingBeep" if Panels.Settings.typingSound ~= Panels.Audio.TypingSound.NONE then if Panels.Settings.typingSound ~= Panels.Audio.TypingSound.DEFAULT then path = Panels.Settings.audioFolder .. Panels.Settings.typingSound end typingSamplePlayer = playdate.sound.sampleplayer.new(path) end end function onBGFinished(player) if player:didUnderrun() then printError("", "Background audio fileplayer stopped due to buffer underrun") end end function Panels.Audio.fileIsPlaying(path) if path == nil then return false end if string.sub(path, -4) == ".wav" then path = string.sub(path, 0, -5) end path = Panels.Settings.audioFolder .. path return bgAudioFile == path and bgAudioPlayer and bgAudioPlayer:isPlaying() end function Panels.Audio.startBGAudio(path, loop, volume) if string.sub(path, -4) == ".wav" then path = string.sub(path, 0, -5) end bgAudioFile = path if bgAudioPlayer then Panels.Audio.fadeOut(bgAudioPlayer) end bgAudioPlayer, error = playdate.sound.fileplayer.new(path, 2) if bgAudioPlayer then bgAudioPlayer:setFinishCallback(onBGFinished, bgAudioPlayer) if loop then repeatCount = 0 else repeatCount = 1 end success, e = bgAudioPlayer:play(repeatCount) if e then printError(e, "Error playing bg audio:") else bgAudioPlayer:setVolume(volume or 1) end else printError(error, "Error loading background audio:") end end function Panels.Audio.stopBGAudio() if bgAudioPlayer then bgAudioPlayer:stop() shouldResume = false end end function Panels.Audio.killBGAudio() if bgAudioPlayer then bgAudioPlayer:stop() shouldResume = false bgAudioPlayer = nil end end function Panels.Audio.fadeOutAndKill() if bgAudioPlayer then local function onFadeComplete(player) player:stop() shouldResume = false player = nil end bgAudioPlayer:setVolume(0, 0, 0.5, onFadeComplete, bgAudioPlayer) end end function Panels.Audio.pauseBGAudio() if bgAudioPlayer and (bgAudioPlayer:isPlaying() or shouldResume) then shouldResume = true bgAudioPlayer:pause() else shouldResume = false end end function Panels.Audio.resumeBGAudio() if bgAudioPlayer and shouldResume then bgAudioPlayer:play(repeatCount) end end function Panels.Audio.bgAudioIsPlaying() return bgAudioPlayer and bgAudioPlayer:isPlaying() end function Panels.Audio.startTypingSound() if not typingIsMuted and typingSamplePlayer then typingRetainCount = typingRetainCount + 1 typingSamplePlayer:play(0) end end function Panels.Audio.stopTypingSound() typingRetainCount = typingRetainCount - 1 if typingSamplePlayer and typingRetainCount <= 0 then typingRetainCount = 0 typingSamplePlayer:stop() end end function Panels.Audio.muteTypingSounds() if typingSamplePlayer then typingIsMuted = true typingRetainCount = 0 typingSamplePlayer:stop() end end function Panels.Audio.unmuteTypingSounds() typingIsMuted = false end function Panels.Audio.fadeOut(player) local function onFadeComplete(player) player:stop() end player:setVolume(0, 0, 1, onFadeComplete, player) end ================================================ FILE: modules/ButtonIndicator.lua ================================================ Panels.ButtonIndicator = {} local ScreenWidth = playdate.display.getWidth() local ScreenHeight = playdate.display.getHeight() local gfx = playdate.graphics Panels.ControlSize = { LARGE = 40, MEDIUM = 30, SMALL = 20, } function Panels.ButtonIndicator.getPosititonForScrollDirection(direction, _size) local size = _size or Panels.ControlSize.LARGE local x = ScreenWidth - size - 2 local y = (ScreenHeight - size) / 2 if direction == Panels.ScrollDirection.RIGHT_TO_LEFT then x = 2 elseif direction == Panels.ScrollDirection.TOP_DOWN then x = (ScreenWidth - size ) / 2 y = ScreenHeight - size - 2 elseif direction == Panels.ScrollDirection.BOTTOM_UP then x = (ScreenWidth - size ) / 2 y = 2 end return x, y end function Panels.ButtonIndicator.new(size) local button = {imageTable = nil, holdFrame = 4} button.currentFrame = 1 button.step = 1 button.state = "hidden" button.x = 0 button.y = 0 button.button = "0" button.size = size or Panels.ControlSize.LARGE button.timer = playdate.timer.new( 50, function() button:updateTimer() end ) button.timer.repeats = true button.timer.paused = true function button:setPosition(x, y) self.x = x self.y = y end function button:setButton(input) if self.button ~= input then local imageName = "" if input == Panels.Input.A then imageName = "buttonA" elseif input == Panels.Input.B then imageName = "buttonB" elseif input == Panels.Input.UP then imageName = "buttonUP" elseif input == Panels.Input.RIGHT then imageName = "buttonRT" elseif input == Panels.Input.DOWN then imageName = "buttonDN" else imageName = "buttonLT" end local imagePathSuffix = "-table-40-40.png" if self.size == Panels.ControlSize.SMALL then imagePathSuffix = "-SM-table-20-20.png" end if self.size == Panels.ControlSize.MEDIUM then imagePathSuffix = "-MD-table-30-30.png" end self.imageTable = gfx.imagetable.new( Panels.Settings.path .. "assets/images/" .. imageName .. imagePathSuffix) end end function button:setPositionForScrollDirection(direction) print("Setting position for scroll direction. size: " .. self.size) local x, y = Panels.ButtonIndicator.getPosititonForScrollDirection(direction, self.size) self:setPosition(x, y) end function button:reset() self.state = "hidden" self.currentFrame = 1 self.timer:pause() self.step = 1 end function button:show() if self.currentFrame == 1 and self.state ~= "showing" then self.state = "showing" self.step = 1 self.timer:start() end end function button:hide() if self.currentFrame <= self.holdFrame and self.state ~= "hiding" then self.state = "hiding" self.step = -1 self.timer:start() end end function button:press() self.state = "pressing" self.timer:pause() self.step = 1 self.currentFrame = self.holdFrame + 1 self.timer:start() end function button:updateTimer() if self.imageTable then self.currentFrame = self.currentFrame + self.step if self.currentFrame < 1 or self.currentFrame >= #self.imageTable then self.currentFrame = 1 self.timer:pause() self.state = "hidden" elseif self.currentFrame == self.holdFrame then self.timer:pause() end end end function button:draw(x, y) if self.imageTable then self.imageTable:drawImage(self.currentFrame, x or self.x, y or self.y) end end return button end ================================================ FILE: modules/ChoiceList.lua ================================================ local gfx = playdate.graphics Panels.ChoiceList = {} local function renderChoiceButton(text, x, y, w, h, radius, fontFamily, selected) if selected then text = "*" .. text .. "*" end gfx.pushContext() -- draw button background with inverted color if gfx.getColor() == Panels.Color.WHITE then gfx.setColor(Panels.Color.BLACK) else gfx.setColor(Panels.Color.WHITE) end gfx.fillRoundRect(x + 3, y + 3, w - 6, h - 6, radius) gfx.popContext() gfx.pushContext() gfx.setLineWidth(1) gfx.drawRoundRect(x + 3, y + 3, w - 6, h - 6, radius) local _tw, textHeight = gfx.getTextSizeForMaxWidth(text, w -6) gfx.drawTextAligned(text, x + (w /2), y + (h / 2) - textHeight/2, Panels.TextAlignment.CENTER) if selected then gfx.setLineWidth(2) gfx.drawRoundRect(x, y, w, h, radius + 2) end gfx.popContext() end local function getDefaultSelection(buttons) for i, button in ipairs(buttons) do if button.selected then return i end end return 1 end local function getButtonAutoSize(buttons, maxWidth, fontFamily ) local buttonW = 0 local buttonH = 0 gfx.pushContext() if(fontFamily) then gfx.setFontFamily(Panels.Font.getFamily(fontFamily)) end for i, button in ipairs(buttons) do -- we have to test both normal and selected sizes -- because sometimes bold is wider and sometimes normal is wider local tw, th = gfx.getTextSize(button.label) local bw, bh = gfx.getTextSize("*" .. button.label .. "*") local w = math.max(tw, bw) local h = math.max(th, bh) if w > buttonW then buttonW = w end if h > buttonH then buttonH = h end end gfx.popContext() return math.min(buttonW + 40, maxWidth), buttonH + 30 end function createPointerTimer(choiceList) pointerTimer = playdate.timer.new(600, 0, 8, playdate.easingFunctions.inOutSine) pointerTimer.reverses = true pointerTimer.repeats = true pointerTimer.updateCallback = function(timer) choiceList.pointerX = timer.value end end function Panels.ChoiceList.new(data, frame, selectionCallback) local choiceList = { buttons = data.buttons, selectedIndex = getDefaultSelection(data.buttons), fontFamily = data.fontFamily, onSelectionChangePanelCallback = selectionCallback, onSelectionChangeUserCallback = data.onSelectionChange, renderButton = data.buttonRenderFunction or renderChoiceButton, didInit = false } choiceList.pointer = gfx.image.new(Panels.Settings.path .. "assets/images/pointer.png") choiceList.pointerX = 0 local color = data.color or Panels.Color.BLACK local autoW, autoH = getButtonAutoSize(choiceList.buttons, math.min(frame.width, 380), choiceList.fontFamily) local w = data.width or autoW local h = data.height or autoH local spacing = data.spacing or 6 local borderRadius = data.borderRadius or 4 local x = data.x or (frame.width - w) / 2 local totalHeight = (#choiceList.buttons * h) + ((#choiceList.buttons -1) * (spacing)) local y = data.y or (frame.height - totalHeight) / 2 function choiceList:render() if(pointerTimer == nil) then createPointerTimer(self) end if not self.didInit then self.didInit = true self.onSelectionChangePanelCallback(self.selectedIndex, self.buttons[self.selectedIndex]) end self:checkInput() gfx.pushContext() if(self.fontFamily) then gfx.setFontFamily(Panels.Font.getFamily(self.fontFamily)) end gfx.setColor(color) if color == Panels.Color.WHITE then gfx.setImageDrawMode(gfx.kDrawModeFillWhite) else gfx.setImageDrawMode(gfx.kDrawModeCopy) end for i, button in ipairs(self.buttons) do self.renderButton(button.label, x, y + (h + spacing) * (i-1), w, h, borderRadius, self.fontFamily, self.selectedIndex == i) end gfx.popContext() local pointerY = y + (self.selectedIndex -1) * (h + spacing) + (h -self.pointer.height )/2 self.pointer:draw(x + w - 16 + self.pointerX, pointerY) end function choiceList:checkInput() if playdate.buttonJustPressed(Panels.Input.UP) then if self.selectedIndex > 1 then self.selectedIndex = self.selectedIndex - 1 self.onSelectionChangePanelCallback(self.selectedIndex, self.buttons[self.selectedIndex]) end elseif playdate.buttonJustPressed(Panels.Input.DOWN) then if self.selectedIndex < #self.buttons then self.selectedIndex = self.selectedIndex + 1 self.onSelectionChangePanelCallback(self.selectedIndex, self.buttons[self.selectedIndex]) end end end function choiceList:reset() pointerTimer:remove() pointerTimer = nil end return choiceList end ================================================ FILE: modules/Color.lua ================================================ Panels.Color = { WHITE = playdate.graphics.kColorWhite, BLACK = playdate.graphics.kColorBlack, CLEAR = playdate.graphics.kColorClear, } function Panels.Color.invert(color) if color == Panels.Color.BLACK then return Panels.Color.WHITE else return Panels.Color.BLACK end end ================================================ FILE: modules/Credits.lua ================================================ local gfx = playdate.graphics local ScreenWidth = playdate.display.getWidth() local ScreenHeight = playdate.display.getHeight() Panels.Credits = {} local qrCode = gfx.image.new(Panels.Settings.path .. "assets/images/panelsPagesQR.png") local url = "cadin.github.io/panels" local maxScroll = 0 local scrollAcceleration = 0.25 local maxScrollVelocity = 6 local scrollVelocity = 0 local snapStrength = 1.5 local headerHeight = 48 local bottomPadding = 24 local panelsCreditHeight = 78 local function createPanelsCredits() local img = gfx.image.new(244, 54, Panels.Color.BLACK) gfx.pushContext(img) gfx.setImageDrawMode(gfx.kDrawModeInverted) qrCode:draw(0, 0) gfx.drawText("*Built with Panels*", 64, 7) gfx.drawText(url, 64, 29) gfx.popContext() return img end local function measureCreditsHeight(credits) local height = 1 if credits.lines == nil then return height end for i, line in ipairs(credits.lines) do if line.text then local w, h = gfx.getTextSize(line.text) height = height + h + (line.spacing or 0) elseif line.image then local img = gfx.image.new(Panels.Settings.imageFolder .. line.image) local w, h = img:getSize() height = height + h + (line.spacing or 0) end end return height end local function getPositionForAlignment(alignment) local x = 32 if alignment == kTextAlignment.center then x = ScreenWidth / 2 elseif alignment == kTextAlignment.right then x = ScreenWidth - 32 end return x end local function getAnchorForAlignment(alignment) local anchor = 0 if alignment == kTextAlignment.center then anchor = 0.5 elseif alignment == kTextAlignment.right then anchor = 1 end return anchor end local function createGameCredits(credits) local textAlignment = credits.alignment or kTextAlignment.center local creditsHeight = measureCreditsHeight(credits) local img = gfx.image.new(400, creditsHeight) local defaultX = getPositionForAlignment(textAlignment) local alignment = textAlignment gfx.pushContext(img) local font = gfx.getSystemFont() if credits.font then font = Panels.Font.get(credits.font) gfx.setFont(font) elseif credits.fontFamily then local family = Panels.Font.getFamily(credits.fontFamily) gfx.setFontFamily(family) elseif Panels.Settings.menuFontFamily then local family = Panels.Font.getFamily(Panels.Settings.menuFontFamily) gfx.setFontFamily(family) else gfx.setFont(font) end local y = 0 if credits.lines then for i, line in ipairs(credits.lines) do local f = font if line.font then f = Panels.Font.get(line.font) gfx.setFont(f) end y = y + (line.spacing or 0) if line.alignment then alignment = line.alignment x = getPositionForAlignment(line.alignment) else alignment = textAlignment x = defaultX end if line.text then gfx.drawTextAligned(line.text, x, y, alignment) local w, h = gfx.getTextSize(line.text) y = y + h elseif line.image then local img = gfx.image.new(Panels.Settings.imageFolder .. line.image) local w, h = img:getSize() local anchorX = getAnchorForAlignment(alignment) img:drawAnchored(x, y, anchorX, 0) y = y + h end end end gfx.popContext() return img end local autoScrollTimeout = nil local isAutoScrolling = false local function startAutoScroll() isAutoScrolling = true end local function killAutoScrolling() isAutoScrolling = false if autoScrollTimeout then autoScrollTimeout:reset() end end function Panels.Credits.new() local data = Panels.credits if data.hideStandardHeader then headerHeight = 8 end local gameCreditsHeight = math.max(measureCreditsHeight(data), 138 - headerHeight) local credits = { gameCredits = createGameCredits(data), panelsImg = createPanelsCredits(), showHeader = not data.hideStandardHeader, scrollPos = 0, isScrollable = false, shouldAutoScroll = data.autoScroll or false, height = gameCreditsHeight + headerHeight + bottomPadding + panelsCreditHeight } if gameCreditsHeight > 138 - headerHeight then credits.isScrollable = true end maxScroll = -(gameCreditsHeight + headerHeight + bottomPadding + panelsCreditHeight - ScreenHeight) function credits:drawPanelsCredits(x, y) gfx.drawLine(0, y, 400, y) gfx.setColor(Panels.Color.BLACK) gfx.fillRect(0, y, 400, 180) -- 78 self.panelsImg:draw(90, y + 12) end function credits:drawHeader(posY) gfx.drawTextAligned("*Credits*", 200, posY + 12, kTextAlignment.center) gfx.setLineWidth(1) gfx.drawLine(32, posY + 20, 32 + 120, posY + 20) gfx.drawLine(368 - 120, posY + 20, 368, posY + 20) end function credits:cranked(change) if self.isScrollable then self.scrollPos += change killAutoScrolling() end end function credits:onDidShow() if self.shouldAutoScroll then autoScrollTimeout = playdate.timer.new(1500, startAutoScroll) autoScrollTimeout.discardOnCompletion = false end end function credits:checkForInput() -- button input if playdate.buttonIsPressed(Panels.Input.DOWN) then scrollVelocity = scrollVelocity - scrollAcceleration killAutoScrolling() elseif playdate.buttonIsPressed(Panels.Input.UP) then scrollVelocity = scrollVelocity + scrollAcceleration killAutoScrolling() else scrollVelocity = scrollVelocity / 2 end -- constrain to min/max if scrollVelocity > maxScrollVelocity then scrollVelocity = maxScrollVelocity elseif scrollVelocity < -maxScrollVelocity then scrollVelocity = -maxScrollVelocity end self.scrollPos = self.scrollPos + scrollVelocity -- snap to bounds if self.scrollPos > 0 then self.scrollPos = math.floor(self.scrollPos / snapStrength) elseif self.scrollPos < maxScroll then local diff = self.scrollPos - maxScroll self.scrollPos = math.floor(self.scrollPos - (diff - (diff / snapStrength ))) end end function credits:redraw(yPos) if self.isScrollable then self:checkForInput() if isAutoScrolling then self.scrollPos = self.scrollPos - 1 end end if self.showHeader then self:drawHeader(self.scrollPos + yPos) end self.gameCredits:draw(0, self.scrollPos + headerHeight + yPos) self:drawPanelsCredits(0, self.scrollPos + gameCreditsHeight + bottomPadding + headerHeight + yPos) end return credits end ================================================ FILE: modules/Effect.lua ================================================ Panels.Effect = { SHAKE_UNISON = 1, SHAKE_INDIVIDUAL = 2, BLINK = 3, TYPE_ON = 4, SHAKE = 2, SHAKE_LAYER = 2, } ================================================ FILE: modules/Font.lua ================================================ Panels.Font = { NORMAL = playdate.graphics.font.kVariantNormal, BOLD = playdate.graphics.font.kVariantBold, ITALIC = playdate.graphics.font.kVariantItalic } local function clipExtension(path) if string.sub(path, -4) == ".fnt" then print("Panels: Don't include '.fnt' extension in font paths") print("- '" .. path .. "'") path = string.sub(path, 0, -5) end return path end local cache = {} function Panels.Font.get(path) path = clipExtension(path) if cache[path] == nil then cache[path] = playdate.graphics.font.new(path) end return cache[path] end local function clipExtensions(paths) for key, value in pairs(paths) do paths[key] = clipExtension(value) end return paths end local families = {} function Panels.Font.getFamily(paths) local key = paths[Panels.Font.NORMAL] if key == nil then key = paths[Panels.Font.BOLD] end if key == nil then key = paths[Panels.Font.ITALIC] end if families[key] == nil then clipExtensions(paths) families[key] = playdate.graphics.font.newFamily(paths) end return families[key] end ================================================ FILE: modules/Image.lua ================================================ Panels.Image = { } local cache = {} function Panels.Image.get(path) local error = nil if cache[path] == nil then cache[path], error = playdate.graphics.image.new(path) end return cache[path], error end function Panels.Image.clearCache() cache = {} end ================================================ FILE: modules/Input.lua ================================================ Panels.Input = { A = playdate.kButtonA, B = playdate.kButtonB, UP = playdate.kButtonUp, DOWN = playdate.kButtonDown, LEFT = playdate.kButtonLeft, RIGHT = playdate.kButtonRight } ================================================ FILE: modules/Layer.lua ================================================ local gfx = playdate.graphics local ScreenHeight = playdate.display.getHeight() local ScreenWidth = playdate.display.getWidth() function Panels.renderLayerInPanel(layer, panel, offset) local pct = getScrollPercentages(panel.frame, offset, panel.axis) local cntrlPct = calculateControlPercent(pct, panel) local p = layer.parallax or 0 local startValues = table.shallow_copy(layer) if layer.isExiting and layer.animate then for k, v in pairs(layer.animate) do startValues[k] = v end end local xPos = math.floor(startValues.x + (panel.parallaxDistance * pct.x - panel.parallaxDistance / 2) * p) local yPos = math.floor(startValues.y + (panel.parallaxDistance * pct.y - panel.parallaxDistance / 2) * p) local rotation = 0 if layer.animate or layer.isExiting then local anim = layer.animate if layer.isExiting then anim = layer.exit anim.scrollTrigger = 0 end if (anim.triggerSequence or anim.scrollTrigger ~= nil) and not layer.animator then if layer.buttonsPressed == nil then layer.buttonsPressed = {} end local triggerButton = nil if not anim.scrollTrigger then triggerButton = anim.triggerSequence[#layer.buttonsPressed + 1] end if anim.scrollTrigger ~= nil or pdButtonJustPressed(triggerButton) then layer.buttonsPressed[#layer.buttonsPressed + 1] = triggerButton if (anim.scrollTrigger ~= nil and cntrlPct >= anim.scrollTrigger) or (anim.triggerSequence and #layer.buttonsPressed == #anim.triggerSequence) then layer.animator = gfx.animator.new((anim.duration or 200), 0, 1, anim.ease, anim.delay) if layer.sfxPlayer then local count = anim.audio.repeatCount or 1 if anim.audio.loop then count = 0 end playdate.timer.performAfterDelay(anim.delay + (anim.audio.delay or 0), function() layer.sfxPlayer:play(count) end) end end end else local layerPct = cntrlPct if layer.animator then layerPct = layer.animator:currentValue() end if anim.x then xPos = math.floor(xPos + ((anim.x - startValues.x) * layerPct)) end if anim.y then yPos = math.floor(yPos + ((anim.y - startValues.y) * layerPct)) end if anim.rotation then rotation = anim.rotation * layerPct end if anim.opacity then local o = (anim.opacity - layer.opacity) * layerPct + layer.opacity layer.alpha = o if o <= 0 then layer.visible = false else layer.visible = true end end end end if panel:layerShouldShake(layer) then if panel.effect and panel.effect.type == Panels.Effect.SHAKE_INDIVIDUAL then shake = calculateShake(panel.effect.strength or 2) elseif layer.effect and layer.effect.type == Panels.Effect.SHAKE then shake = calculateShake(layer.effect.strength or 2) end xPos = xPos + shake.x * (1 - p * p) yPos = yPos + shake.y * (1 - p * p) end if layer.effect then doLayerEffect(layer, xPos, yPos) end local img if layer.img then img = layer.img elseif layer.imgs then if layer.advanceControl then if pdButtonJustPressed(layer.advanceControl) then if layer.currentImage < #layer.imgs then layer.currentImage = layer.currentImage + 1 end end img = layer.imgs[layer.currentImage] else local p = cntrlPct p = p - (panel.transitionOffset or 0) p = p - (layer.transitionOffset or 0) local j = math.max(math.min(math.ceil(p * #layer.imgs), #layer.imgs), 1) img = layer.imgs[j] end end local globalX = xPos + offset.x + panel.frame.x local globalY = yPos + offset.y + panel.frame.y if img then if layer.visible then if globalX + img.width > 0 and globalX < ScreenWidth and globalY + img.height > 0 and globalY < ScreenHeight then if layer.alpha and layer.alpha < 1 then img:drawFaded(xPos, yPos, layer.alpha, playdate.graphics.image.kDitherTypeBayer8x8) else if layer.maskImg then local maskX = math.floor((panel.parallaxDistance * pct.x - panel.parallaxDistance / 2) * p) - panel.frame.margin local maskY = math.floor((panel.parallaxDistance * pct.y - panel.parallaxDistance / 2) * p) - panel.frame.margin local maskImg = gfx.image.new(ScreenWidth, ScreenHeight) gfx.lockFocus(maskImg) layer.maskImg:draw(maskX, maskY) gfx.unlockFocus() gfx.setStencilImage(maskImg) img:draw(xPos, yPos) gfx.clearStencil() else img:draw(xPos, yPos) end end end end elseif layer.text then if layer.visible then if globalX + ScreenWidth > 0 and globalX < ScreenWidth and globalY + ScreenHeight > 0 and globalY < ScreenHeight then if layer.alpha == nil or layer.alpha > 0 then panel:drawTextLayer(layer, xPos, yPos, cntrlPct) end end end elseif layer.animationLoop then if layer.visible then if layer.trigger then if pdButtonJustPressed(layer.trigger) then layer.animationLoop.paused = false end elseif layer.startDelay then if layer.startDelayTriggered == nil then playdate.timer.performAfterDelay(layer.startDelay, function() if layer.animationLoop then layer.animationLoop.paused = false end end) layer.startDelayTriggered = true end elseif cntrlPct >= layer.scrollTrigger then layer.animationLoop.paused = false end layer.animationLoop:draw(xPos, yPos) end end end ================================================ FILE: modules/Menus.lua ================================================ import 'CoreLibs/ui/gridview.lua' local gfx = playdate.graphics local ScreenWidth = playdate.display.getWidth() local ScreenHeight = playdate.display.getHeight() local selectionSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/selection.wav") local selectionRevSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/selection-reverse.wav") local denialSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/denial.wav") local confirmSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/confirm.wav") local hideSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/swish-out.wav") local showSound = playdate.sound.sampleplayer.new(Panels.Settings.path .. "assets/audio/swish-in.wav") local headerFont = gfx.getSystemFont("bold") local listFont = gfx.getSystemFont() Panels.Menu = {} -- ------------------------------------------------- -- GENERIC MENU local menuAnimationDuration = 200 local MenuState = { SHOWING = 0, OPEN = 1, HIDING = 2, CLOSED = 3 } function Panels.Menu.new(height, redrawContent, inputHandlers) local menu = {} menu.animator = nil menu.state = MenuState.CLOSED menu.isFullScreen = false menu.onWillShow = nil menu.onDidShow = nil menu.onDidHide = nil if Panels.Settings.menuFontFamily then local family = Panels.Font.getFamily(Panels.Settings.menuFontFamily) headerFont = family listFont = family elseif Panels.Settings.defaultFontFamily then local family = Panels.Font.getFamily(Panels.Settings.defaultFontFamily) headerFont = family listFont = family end local function drawBG(yPos) gfx.setColor(Panels.Color.WHITE) gfx.fillRoundRect(0, yPos, 400, ScreenHeight + 5, 4) end local function drawOutline(yPos) gfx.setColor(Panels.Color.BLACK) gfx.setLineWidth(2) gfx.drawRoundRect(0, yPos, 400, ScreenHeight + 5, 4) end function menu:show() if self.state == MenuState.SHOWING or self.state == MenuState.OPEN then return end if self.onWillShow then self:onWillShow() end Panels.onMenuWillShow(self) self.state = MenuState.SHOWING playdate.inputHandlers.push(inputHandlers, true) self.animator = gfx.animator.new(menuAnimationDuration, 0, 1, playdate.easingFunctions.inOutQuad) if Panels.Settings.playMenuSounds then if self ~= Panels.mainMenu then showSound:play() end end end function menu:hide() if self.state == MenuState.HIDING or self.state == MenuState.CLOSED then return end Panels.onMenuWillHide(self) self.state = MenuState.HIDING playdate.inputHandlers.pop() self.animator = gfx.animator.new(menuAnimationDuration, 1, 0, playdate.easingFunctions.inOutQuad) if Panels.Settings.playMenuSounds then hideSound:play() end end function menu:isActive() return self.state ~= MenuState.CLOSED end function menu:updateState() if self.animator:ended() then if self.state == MenuState.SHOWING then self.state = MenuState.OPEN Panels.onMenuDidShow(self) if self.onDidShow then self:onDidShow() end elseif self.state == MenuState.HIDING then self.state = MenuState.CLOSED Panels.onMenuDidHide(self) end end end function menu:update() local animatorVal = self.animator:currentValue() local yPos = ScreenHeight - animatorVal * height if yPos < ScreenHeight then drawBG(yPos) redrawContent(yPos) drawOutline(yPos) end self:updateState() end return menu end -- ------------------------------------------------- -- MAIN MENU local mainMenuList = nil local menuOptions = { "Start Over" } local mainMenuImage = nil local function displayMenuImage(val) local y = 240 - (val * ScreenHeight) mainMenuImage:drawFaded(0, 0, val, gfx.image.kDitherTypeBayer8x8) end local function loadMenuImage() img, error = gfx.image.new(Panels.Settings.imageFolder .. Panels.Settings.menuImage) printError(error, "Error loading main menu image:") if img == nil then img = gfx.image.new(ScreenWidth, ScreenHeight, Panels.Color.WHITE) end return img end local function redrawMainMenu(yPos) mainMenuList:drawInRect(8, yPos + 3, 384, 42) end local mainOffset = 0 local function updateMainMenu(gameDidFinish, gameDidStart) menuOptions = { "Start Over" } if Panels.Settings.useChapterMenu then menuOptions[#menuOptions+1] = "Chapters" end if not gameDidFinish then if gameDidStart then menuOptions[#menuOptions+1] = "Resume" end end if #menuOptions == 1 then if gameDidFinsish then menuOptions = { "Play Again" } else menuOptions = { "Start" } end end mainMenuList = playdate.ui.gridview.new(math.floor((ScreenWidth - 16) / #menuOptions) - 8, 32) mainMenuList:setNumberOfRows(1) mainMenuList:setNumberOfColumns(#menuOptions) mainMenuList:setCellPadding(4, 4, 4, 4) mainMenuList:setSelection(1, 1, #menuOptions) function mainMenuList:drawCell(section, row, column, selected, x, y, width, height) gfx.pushContext() local text = menuOptions[column] if selected then gfx.setColor(gfx.kColorBlack) gfx.fillRoundRect(x + mainOffset, y, width , height, 4) gfx.setImageDrawMode(gfx.kDrawModeFillWhite) text = "*" .. text .. "*" mainOffset = 0 else gfx.setImageDrawMode(gfx.kDrawModeCopy) end gfx.setFont(listFont) gfx.drawTextInRect(text, x, y+8, width, height+2, nil, "...", kTextAlignment.center) gfx.popContext() end end function createMainMenu(gameDidFinish, gameDidStart) mainMenuImage = loadMenuImage() updateMainMenu(gameDidFinish, gameDidStart) local inputHandlers = { rightButtonUp = function() local s, r, column = mainMenuList:getSelection() if Panels.Settings.playMenuSounds then if column == #menuOptions then denialSound:play() else selectionSound:play() end end mainOffset = 4 mainMenuList:selectNextColumn(false) end, leftButtonUp = function() local s, r, column = mainMenuList:getSelection() if Panels.Settings.playMenuSounds then if column == 1 then denialSound:play() else selectionRevSound:play() end end mainOffset = -4 mainMenuList:selectPreviousColumn(false) end, AButtonDown = function() local s, r, column = mainMenuList:getSelection() local label if Panels.Settings.playMenuSounds then confirmSound:play() end if column == #menuOptions and not gameDidFinish then -- Continue Panels.mainMenu:hide() elseif column == 1 then -- Start Over Panels.onMenuDidStartOver() elseif Panels.Settings.useChapterMenu then -- Chapters Panels.chapterMenu:show() end end, } local menu = Panels.Menu.new(45, redrawMainMenu, inputHandlers) return menu end -- ------------------------------------------------- -- CHAPTER MENU local chapterList = playdate.ui.gridview.new(0, 32) local headerImage = nil local maxUnlockedChapter = 0 local function createSectionsFromData(data) sections = {} maxUnlockedChapter = 0 for i, seq in ipairs(data) do if (seq.title or Panels.Settings.listUnnamedSequences) and (Panels.unlockedSequences[i] == true or Panels.Settings.listLockedSequences) then local title = seq.title or "--" if Panels.unlockedSequences[i] == true then title = "*" .. title .. "*" maxUnlockedChapter = maxUnlockedChapter + 1 end sections[#sections + 1] = {title = title, index = i} end end end local function redrawChapterMenu(yPos) chapterList:drawInRect(13, yPos +1, 374, 240) end local function onChapterMenuWillShow() chapterList:setSelectedRow(1) chapterList:selectPreviousRow() end local function updateChapterMenu(data) createSectionsFromData(data) chapterList:setNumberOfRows(#sections) end local function isLastUnlockedSequence(index) for i = index + 1, #Panels.unlockedSequences, 1 do if i > #sections then return true end local sectionIndex = sections[i].index if Panels.unlockedSequences[sectionIndex] == true then return false end end return true end local function isFirstUnlockedSequence(index) for i = index - 1, 1, -1 do local sectionIndex = sections[i].index if Panels.unlockedSequences[sectionIndex] == true then return false end end return true end local function getNextUnlockedSequence(index) for i = index + 1, #Panels.unlockedSequences, 1 do local sectionIndex = sections[i].index if Panels.unlockedSequences[sectionIndex] == true then return i end end return nil end local function getPreviousUnlockedSequence(index) for i = index - 1, 1, -1 do local sectionIndex = sections[i].index if Panels.unlockedSequences[sectionIndex] == true then return i end end return nil end local function getRowForSequenceIndex(index) for i, sec in ipairs(sections) do if sec.index == index then return i end end return nil end local chapterOffset = 0 local function createChapterMenu(data) updateChapterMenu(data) if Panels.Settings.chapterMenuHeaderImage then headerImage = gfx.image.new(Panels.Settings.imageFolder .. Panels.Settings.chapterMenuHeaderImage) local w, h = headerImage:getSize() chapterList:setSectionHeaderHeight(h + 24) else chapterList:setSectionHeaderHeight(48) end chapterList:setCellPadding(0, 0, 0, 8) local inputHandlers = { downButtonUp = function() chapterOffset = 4 local selectedRow = chapterList:getSelectedRow() if not isLastUnlockedSequence(selectedRow) then local next = getNextUnlockedSequence(selectedRow) chapterList:setSelectedRow(next) chapterList:scrollToRow(next) if Panels.Settings.playMenuSounds then selectionSound:play() end else if Panels.Settings.playMenuSounds then denialSound:play() end end end, upButtonUp = function() chapterOffset = -4 local selectedRow = chapterList:getSelectedRow() if not isFirstUnlockedSequence(selectedRow) then local prev = getPreviousUnlockedSequence(selectedRow) chapterList:setSelectedRow(prev) chapterList:scrollToRow(prev) if Panels.Settings.playMenuSounds then selectionRevSound:play() end else if Panels.Settings.playMenuSounds then denialSound:play() end end end, AButtonDown = function() local item = sections[chapterList:getSelectedRow()] Panels.onChapterSelected( item.index ) Panels.chapterMenu:hide() if Panels.mainMenu then Panels.mainMenu:hide() end if Panels.Settings.playMenuSounds then confirmSound:play() end end, BButtonDown = function() Panels.chapterMenu:hide() end } local menu = Panels.Menu.new(ScreenHeight, redrawChapterMenu, inputHandlers) menu.onWillShow = onChapterMenuWillShow return menu end function chapterList:drawCell(section, row, column, selected, x, y, width, height) gfx.pushContext() if selected then gfx.setColor(gfx.kColorBlack) gfx.fillRoundRect(x, y + chapterOffset, width, height, 4) gfx.setImageDrawMode(gfx.kDrawModeFillWhite) chapterOffset = 0 else gfx.setImageDrawMode(gfx.kDrawModeCopy) end gfx.setFont(listFont) gfx.drawTextInRect("" .. sections[row].title.. "", x + 16, y+8, width -32, height+2, nil, "...", kTextAlignment.left) gfx.popContext() end function chapterList:drawSectionHeader(section, x, y, width, height) gfx.pushContext() if Panels.Settings.chapterMenuHeaderImage then headerImage:drawAnchored(x + width / 2, y + 7, 0.5, 0) else gfx.setColor(gfx.kColorBlack) gfx.setFont(headerFont) gfx.drawTextInRect("Chapters", x, y+12, width, height, nil, "...", kTextAlignment.center) gfx.setLineWidth(1) gfx.drawLine(x, y + 20, x + 120, y + 20) gfx.drawLine(x + width - 120, y + 20, x + width, y + 20) end gfx.popContext() end -- ------------------------------------------------- -- CREDITS MENU local credits = nil local function redrawCreditsMenu(yPos) credits:redraw(yPos) end local function onCreditsMenuWillShow() credits.scrollPos = 0 end local function onCreditsMenuDidShow() credits:onDidShow() end local function createCreditsMenu() credits = Panels.Credits.new() local inputHandlers = { BButtonDown = function() Panels.creditsMenu:hide() end, cranked = function(change) credits:cranked(change) end, } local menu = Panels.Menu.new(ScreenHeight, redrawCreditsMenu, inputHandlers) menu.onWillShow = onCreditsMenuWillShow menu.onDidShow = onCreditsMenuDidShow return menu end -- ------------------------------------------------- -- ALL MENUS function updateMenus() if Panels.mainMenu and Panels.mainMenu:isActive() then local val = Panels.mainMenu.animator:currentValue() displayMenuImage(val) if Panels.mainMenuDrawingCallBack ~= nil then Panels.mainMenuDrawingCallBack(val) end Panels.mainMenu:update() end if Panels.chapterMenu and Panels.chapterMenu:isActive() then Panels.chapterMenu:update() end if Panels.creditsMenu:isActive() then Panels.creditsMenu:update() end end function createMenus(sequences, gameDidFinish, gameDidStart) Panels.mainMenu = createMainMenu(gameDidFinish, gameDidStart) if Panels.Settings.useChapterMenu then Panels.chapterMenu = createChapterMenu(sequences) end Panels.creditsMenu = createCreditsMenu() end function updateMenuData(sequences, gameDidFinish, gameDidStart) -- updateMainMenu(gameDidFinish, gameDidStart) -- just recreate the damn thing so the inputHandlers have the right state Panels.mainMenu = createMainMenu(gameDidFinish, gameDidStart) updateChapterMenu(sequences) end ================================================ FILE: modules/Panel.lua ================================================ Panels.Panel = {} local gfx = playdate.graphics local ScreenHeight = playdate.display.getHeight() local ScreenWidth = playdate.display.getWidth() local reduceFlashing = playdate.getReduceFlashing() local pdButtonJustPressed = playdate.buttonJustPressed local AxisHorizontal = Panels.ScrollAxis.HORIZONTAL local function createFrameFromPartialFrame(frame) if frame.margin == nil then frame.margin = Panels.Settings.defaultFrame.margin end if frame.width == nil then frame.width = ScreenWidth - frame.margin * 2 end if frame.height == nil then frame.height = ScreenHeight - frame.margin * 2 end if frame.x == nil then frame.x = frame.margin end if frame.y == nil then frame.y = frame.margin end if frame.gap == nil then frame.gap = Panels.Settings.defaultFrame.gap end return frame end function getScrollPercentages(frame, offset, axis) if offset == nil then return {x = 0.5, y - 0.5} end local xPct = 1 - (frame.x - frame.margin + frame.width + offset.x) / (ScreenWidth + frame.width) local yPct = 1 - (frame.y - frame.margin + frame.height + offset.y) / (ScreenHeight + frame.height) local pct = { x = xPct, y = yPct } if axis == AxisHorizontal then pct.y = 0.5 else pct.x = 0.5 end return pct end local function calculateShake(strength) return { x = math.random(-strength, strength), y = math.random(-strength, strength) } end function doLayerEffect(layer) if layer.effect.type == Panels.Effect.BLINK then if layer.timer == nil then if layer.effect.delay then layer.visible = false layer.timer = playdate.timer.new(layer.effect.delay) layer.timer.repeats = false else layer.timer = playdate.timer.new(layer.effect.durations.on + layer.effect.durations.off) layer.timer.repeats = true end else if layer.effect.delay then if layer.timer.currentTime >= layer.effect.delay then layer.effect.delay = false layer.timer = playdate.timer.new(layer.effect.durations.on + layer.effect.durations.off) layer.timer.repeats = true end else if layer.timer.currentTime < layer.effect.durations.on then if layer.visible == false then layer.visible = true if layer.sfxPlayer then layer.sfxPlayer:play() end end else layer.visible = false end end end end end function Panels.Panel.new(data) local panel = table.shallowcopy(data) panel.prevPct = 0 panel.frame = createFrameFromPartialFrame(panel.frame) panel.buttonsPressed = {} panel.willEnableInput = false panel.inputEnabled = false if not panel.parallaxDistance then if panel.axis == Panels.ScrollAxis.HORIZONTAL then panel.parallaxDistance = panel.frame.width * 1.2 else panel.parallaxDistance = panel.frame.height * 1.2 end end if panel.panels then for i, p in ipairs(panel.panels) do panel.panels[i] = Panels.Panel.new(p) end end if panel.advanceControlPositions then panel.advanceControlPosition = panel.advanceControlPositions[1] end local imageFolder = Panels.Settings.imageFolder if panel.showAdvanceControl then panel.advanceButton = Panels.ButtonIndicator.new(panel.advanceControlSize) panel.advanceButton:setButton(panel.advanceControl) if panel.advanceControlPosition then panel.advanceButton:setPosition(panel.advanceControlPosition.x, panel.advanceControlPosition.y) else panel.advanceButton:setPositionForScrollDirection(panel.direction, panel.advanceControlSize) end end if panel.layers then for i, layer in ipairs(panel.layers) do if layer.image then layer.img, error = Panels.Image.get(imageFolder .. layer.image) printError(error, "Error loading image on layer") end if layer.images then layer.imgs = {} layer.currentImage = 1 for j, image in ipairs(layer.images) do layer.imgs[j], error = Panels.Image.get(imageFolder .. image) printError(error, "Error loading images[" .. j .. "] on layer") end end if layer.imageTable then local imgTable, error = gfx.imagetable.new(Panels.Settings.imageFolder .. layer.imageTable) printError(error, "Error loading imagetable on layer") local delay = layer.delay or 200 if layer.reduceFlashingDelay and reduceFlashing then delay = layer.reduceFlashingDelay end local anim = gfx.animation.loop.new(delay, imgTable, layer.loop or false) anim.paused = true if layer.scrollTrigger == nil then layer.scrollTrigger = 0 end layer.animationLoop = anim layer.imgTable = imgTable end if layer.stencil then mask, error = Panels.Image.get(imageFolder .. layer.stencil) layer.maskImg = mask printError(error, "Error loading stencil image on layer") end if layer.x == nil then layer.x = -panel.frame.margin end if layer.y == nil then layer.y = -panel.frame.margin end if layer.visible == nil then layer.visible = true end layer.alpha = layer.opacity or nil if layer.effect then if layer.effect.type == Panels.Effect.BLINK and layer.effect.audio then layer.sfxPlayer = playdate.sound.sampleplayer.new(Panels.Settings.audioFolder .. layer.effect.audio.file) end if reduceFlashing and layer.effect.type == Panels.Effect.BLINK and layer.effect.reduceFlashingDurations ~= nil then layer.effect.durations.on = layer.effect.reduceFlashingDurations.on layer.effect.durations.off = layer.effect.reduceFlashingDurations.off end end if layer.animate then if layer.animate.delay == nil then layer.animate.delay = 0 end if layer.animate.duration then if layer.animate.duration < 1 then layer.animate.duration = 1 end end if layer.animate.autoStart then layer.animate.scrollTrigger = 0 end if layer.animate.scrollTrigger and layer.animate.duration == nil then layer.animate.duration = 200 end if layer.opacity == nil then layer.opacity = 1 end if layer.animate.trigger then layer.animate.triggerSequence = { layer.animate.trigger } end if layer.animate.audio then layer.sfxPlayer = playdate.sound.sampleplayer.new(Panels.Settings.audioFolder .. layer.animate.audio.file) end end end end if panel.audio then panel.sfxPlayer = playdate.sound.sampleplayer.new(Panels.Settings.audioFolder .. panel.audio.file) if panel.audio.pan then panel.sfxPlayer:setVolume(1 - panel.audio.pan, panel.audio.pan) end panel.sfxTrigger = panel.audio.scrollTrigger or 0 end if panel.choiceList then -- Create a wrapper function that preserves the panel context local function selectionCallback(index, button) panel.onChoiceListSelectionChange(index, button) end if panel.choiceList.fontFamily == nil then panel.choiceList.fontFamily = panel.fontFamily end panel.choices = Panels.ChoiceList.new(panel.choiceList, panel.frame, selectionCallback) end function panel:enableInput(isOn) self.willEnableInput = isOn end function panel:nextAdvanceControl(controlIndex, show) if not self.inputEnabled then return end local control = self.advanceControlSequence[controlIndex] if control and self.advanceButton then self.advanceButton:reset() self.advanceButton:setButton(control) self.advanceControl = control if self.advanceControlPositions then local pos = self.advanceControlPositions[controlIndex] if pos then self.advanceButton:setPosition(pos.x, pos.y) end end if show then self.advanceButton:show() end end end function panel:isOnScreen(offset) local isOn = false local f = self.frame if f.x + offset.x <= ScreenWidth and f.x + f.width + offset.x > 0 and f.y + offset.y <= ScreenHeight and f.y + f.height + offset.y > 0 then isOn = true end return isOn end function panel:fadePanelVolume(pct) local vol = 1 if pct < 0.25 then vol = pct / 0.25 elseif pct > 0.75 then vol = (1 - pct) / 0.25 end local leftPan = self.audio.volume or 1 local rightPan = self.audio.volume or 1 if self.audio.pan then leftPan = 1 - self.audio.pan rightPan = self.audio.pan end self.sfxPlayer:setVolume(vol * leftPan, vol * rightPan) end function panel:pauseSounds() if self.sfxPlayer then self.soundIsPaused = true self.sfxPlayer:setPaused(true) end end function panel:unPauseSounds() if self.sfxPlayer then self.soundIsPaused = false self.sfxPlayer:setPaused(false) end end function panel:updatePanelAudio(offset) local pct = getScrollPercentages(self.frame, offset, self.axis) local cntrlPct = calculateControlPercent(pct, self) local count = self.audio.repeatCount or 1 if self.audio.loop then count = 0 end if self.audio.triggerSequence then if self.inputEnabled then if self.audioTriggersPressed == nil then self.audioTriggersPressed = {} end local triggerButton = self.audio.triggerSequence[#self.audioTriggersPressed + 1] if pdButtonJustPressed(triggerButton) then self.audioTriggersPressed[#self.audioTriggersPressed + 1] = triggerButton if #self.audioTriggersPressed == #self.audio.triggerSequence then playdate.timer.performAfterDelay(self.audio.delay or 0, function() if self.sfxPlayer then self.sfxPlayer:play(count) end end) if self.audio.repeats ~= nil then if self.audioRepeats == nil then self.audioRepeats = 1 end if self.audio.repeats > self.audioRepeats then self.audioTriggersPressed = {} self.audioRepeats = self.audioRepeats + 1 end end end end end elseif (cntrlPct < 1 and cntrlPct >= self.sfxTrigger) and (self.prevPct <= self.sfxTrigger or self.audio.loop) then if not self.sfxPlayer:isPlaying() and not self.soundIsPaused then playdate.timer.performAfterDelay(self.audio.delay or 0, function() if self.sfxPlayer then self.sfxPlayer:play(count) end end) end end self:fadePanelVolume(cntrlPct) end function panel:layerShouldShake(layer) local result = false if self.effect and (self.effect.type == Panels.Effect.SHAKE_UNISON or self.effect.type == Panels.Effect.SHAKE_INDIVIDUAL) then result = true end if layer.effect and layer.effect.type == Panels.Effect.SHAKE then result = true end return result end function panel:exit() if self.layers then for i, layer in ipairs(self.layers) do if layer.exit then layer.isExiting = true layer.animator = nil end end end end function calculateControlPercent(scrollPercentages, panel) local cntrlPct = 0 if panel.axis == AxisHorizontal then cntrlPct = scrollPercentages.x else cntrlPct = scrollPercentages.y end if panel.scrollingIsReversed then cntrlPct = 1 - cntrlPct end return cntrlPct end function layerShouldRender(layer) if layer.renderCondition then if Panels.vars[layer.renderCondition.var] ~= nil then if layer.renderCondition.value ~= nil then if Panels.vars[layer.renderCondition.var] == layer.renderCondition.value then return true else return false end elseif layer.renderCondition.valueNot then if Panels.vars[layer.renderCondition.var] ~= layer.renderCondition.valueNot then return true else return false end end else if not layer.didWarnForInvalidRenderCondition then -- just print this once per layer printError("No value for '" .. layer.renderCondition.var .. "' found in Panels.vars", "Invalid renderCondition") layer.didWarnForInvalidRenderCondition = true end if layer.renderCondition.value == false or layer.renderCondition.valueNot ~= nil then -- match nil value to false condition return true else return false end end end return true end function panel:drawLayers(offset) local layers = self.layers local frame = self.frame local shake local pct = getScrollPercentages(frame, offset, self.axis) local cntrlPct = calculateControlPercent(pct, self) if self.effect then if self.effect.type == Panels.Effect.SHAKE_UNISON then shake = calculateShake(self.effect.strength) end end if layers then for i, layer in ipairs(layers) do if not layerShouldRender(layer) then goto continue end local p = layer.parallax or 0 local startValues = table.shallow_copy(layer) if layer.isExiting and layer.animate then for k, v in pairs(layer.animate) do startValues[k] = v end end local xPos = math.floor(startValues.x + (self.parallaxDistance * pct.x - self.parallaxDistance / 2) * p) local yPos = math.floor(startValues.y + (self.parallaxDistance * pct.y - self.parallaxDistance / 2) * p) local rotation = 0 if layer.animate or layer.isExiting then local anim = layer.animate if layer.isExiting then anim = layer.exit anim.scrollTrigger = 0 end if (anim.triggerSequence or anim.scrollTrigger ~= nil) and not layer.animator then if layer.buttonsPressed == nil then layer.buttonsPressed = {} end local triggerButton = nil if not anim.scrollTrigger and self.inputEnabled then triggerButton = anim.triggerSequence[#layer.buttonsPressed + 1] end if anim.scrollTrigger ~= nil or (pdButtonJustPressed(triggerButton) and self.inputEnabled) then layer.buttonsPressed[#layer.buttonsPressed + 1] = triggerButton if (anim.scrollTrigger ~= nil and cntrlPct >= anim.scrollTrigger) or (anim.triggerSequence and #layer.buttonsPressed == #anim.triggerSequence) then layer.animator = gfx.animator.new((anim.duration or 200), 0, 1, anim.ease, anim.delay) if layer.sfxPlayer then local count = anim.audio.repeatCount or 1 if anim.audio.loop then count = 0 end playdate.timer.performAfterDelay(anim.delay + (anim.audio.delay or 0), function() if layer.sfxPlayer then layer.sfxPlayer:play(count) end end) end end end else local layerPct = cntrlPct if layer.animator then layerPct = layer.animator:currentValue() end if anim.x then xPos = math.floor(xPos + ((anim.x - startValues.x) * layerPct)) end if anim.y then yPos = math.floor(yPos + ((anim.y - startValues.y) * layerPct)) end if anim.rotation then rotation = anim.rotation * layerPct end if anim.opacity then local o = (anim.opacity - layer.opacity) * layerPct + layer.opacity layer.alpha = o if o <= 0 then layer.visible = false else layer.visible = true end end end end if self:layerShouldShake(layer) then if self.effect and self.effect.type == Panels.Effect.SHAKE_INDIVIDUAL then shake = calculateShake(self.effect.strength or 2) elseif layer.effect and layer.effect.type == Panels.Effect.SHAKE then shake = calculateShake(layer.effect.strength or 2) end xPos = xPos + shake.x * (1 - p * p) yPos = yPos + shake.y * (1 - p * p) end if layer.pixelLock then -- offset gets added here to ensure the layer position + offset gets rounded properly -- then subtract the offset because it's applied at the panel level local offX = math.floor(offset.x) local offY = math.floor(offset.y) xPos = math.floor((xPos + offX) / layer.pixelLock) * layer.pixelLock - offX yPos = math.floor((yPos + offY) / layer.pixelLock) * layer.pixelLock - offY end if layer.effect then doLayerEffect(layer, xPos, yPos) end local img if layer.img then img = layer.img elseif layer.imgs then if layer.advanceControl then if pdButtonJustPressed(layer.advanceControl) and self.inputEnabled then if layer.currentImage < #layer.imgs then layer.currentImage = layer.currentImage + 1 end end img = layer.imgs[layer.currentImage] elseif layer.manuallySetImageIndex then img = layer.imgs[layer.currentImage] else local p = cntrlPct p = p - (self.transitionOffset or 0) p = p - (layer.transitionOffset or 0) local j = math.max(math.min(math.ceil(p * #layer.imgs), #layer.imgs), 1) img = layer.imgs[j] end end local globalX = xPos + offset.x + self.frame.x local globalY = yPos + offset.y + self.frame.y if img then if layer.visible then if globalX + img.width > 0 and globalX < ScreenWidth and globalY + img.height > 0 and globalY < ScreenHeight then if layer.alpha and layer.alpha < 1 then img:drawFaded(xPos, yPos, layer.alpha, playdate.graphics.image.kDitherTypeBayer8x8) else if layer.maskImg then local maskX = math.floor((self.parallaxDistance * pct.x - self.parallaxDistance / 2) * p) - panel.frame.margin + offset.x + panel.frame.x local maskY = math.floor((self.parallaxDistance * pct.y - self.parallaxDistance / 2) * p) - panel.frame.margin + offset.y + panel.frame.y local maskImg = gfx.image.new(ScreenWidth, ScreenHeight) gfx.lockFocus(maskImg) layer.maskImg:draw(maskX, maskY) gfx.unlockFocus() gfx.setStencilImage(maskImg) img:draw(xPos, yPos) gfx.clearStencil() else img:draw(xPos, yPos) end end end end elseif layer.text then if layer.visible then local widthLimit = ScreenWidth local heightLimit = ScreenHeight if layer.rect and layer.rect.width > ScreenWidth then widthLimit = layer.rect.width end if layer.rect and layer.rect.height > ScreenHeight then heightLimit = layer.rect.height end if globalX + widthLimit > 0 and globalX < widthLimit and globalY + heightLimit > 0 and globalY < heightLimit then self:drawTextLayer(layer, xPos, yPos, cntrlPct) end end elseif layer.animationLoop then if layer.visible then if layer.trigger then if pdButtonJustPressed(layer.trigger) and self.inputEnabled then layer.animationLoop.paused = false end elseif layer.startDelay then if layer.startDelayTriggered == nil then playdate.timer.performAfterDelay(layer.startDelay, function() if layer.animationLoop then layer.animationLoop.paused = false end end) layer.startDelayTriggered = true end elseif cntrlPct >= layer.scrollTrigger then layer.animationLoop.paused = false end layer.animationLoop:draw(xPos, yPos) end end ::continue:: end end self.prevPct = cntrlPct end function panel:setup() if self.setupFunction then self:setupFunction() end end function panel:reset() if self.resetFunction then self:resetFunction() end self:killTypingEffects() if self.sfxPlayer then self.sfxPlayer:stop() end if self.layers then for i, layer in ipairs(self.layers) do layer.startDelayTriggered = nil if layer.animationLoop then if layer.animationLoop.frame ~= 1 then layer.animationLoop.frame = 1 end layer.animationLoop.paused = true end layer.isExiting = false if layer.animator then layer.animator = nil end if layer.opacity then layer.alpha = layer.opacity else layer.alpha = nil end if layer.sfxPlayer then layer.sfxPlayer:stop() end if layer.textAnimator then layer.textAnimator = nil end if layer.cachedTextImg then if(self.prevPct < 0.5) then layer.cachedTextImg = nil end end if layer.images then layer.currentImage = 1 end layer.buttonsPressed = nil layer.visible = true end end self.buttonsPressed = {} self.audioTriggersPressed = {} self.audioRepeats = 1 self.advanceControlTimerDidEnd = false self.advanceControlTimer = nil self.autoAdvanceDidComplete = false self.autoAdvanceTimerDidStart = false if self.autoAdvanceTimer then self.autoAdvanceTimer:remove() self.autoAdvanceTimer = nil end if self.advanceControlSequence and #self.advanceControlSequence > 1 then self:nextAdvanceControl(1, false) end if self.advanceButton then self.advanceButton:reset() end if self.choices then self.choices:reset() -- self.choices = nil end if self.prevPct > 0.5 then self.prevPct = 1 else self.prevPct = 0 end end local function startLayerTypingSound(layer) if layer.isTyping then Panels.Audio.startTypingSound() end end local textMarginHorizontal = 4 local textMarginVertical = 1 function panel:drawTextLayer(layer, xPos, yPos, cntrlPct) if(layer.cachedTextImg == nil) then local textW = layer.rect and layer.rect.width + layer.x or ScreenWidth local textH = layer.rect and layer.rect.height + layer.y or ScreenHeight layer.cachedTextImg = gfx.image.new(textW, textH) layer.needsRedraw = true end local textMarginLeft = layer.margin and (layer.margin.left or layer.margin.h) or textMarginHorizontal local textMarginRight = layer.margin and (layer.margin.right or layer.margin.h) or textMarginHorizontal local textMarginTop = layer.margin and (layer.margin.top or layer.margin.v) or textMarginVertical local textMarginBottom = layer.margin and (layer.margin.bottom or layer.margin.v) or textMarginVertical local lineHeight = layer.lineHeightAdjustment or self.lineHeightAdjustment or 0 if(layer.isTyping or layer.needsRedraw) then layer.needsRedraw = false gfx.pushContext(layer.cachedTextImg) gfx.clear(gfx.kColorClear) if layer.fontFamily then gfx.setFontFamily(Panels.Font.getFamily(layer.fontFamily)) elseif self.fontFamily then gfx.setFontFamily(Panels.Font.getFamily(self.fontFamily)) elseif layer.font then gfx.setFont(Panels.Font.get(layer.font)) elseif self.font then gfx.setFont(Panels.Font.get(self.font)) end local txt = layer.text if layer.effect then if layer.effect.type == Panels.Effect.TYPE_ON then if layer.textAnimator == nil then if self.prevPct == 1 then -- don't replay text animation (and sound) when backing into a frame txt = layer.text layer.needsRedraw = false layer.textAnimator = gfx.animator.new(1, string.len(layer.text), string.len(layer.text)) elseif layer.effect.scrollTrigger == nil or cntrlPct >= layer.effect.scrollTrigger then layer.isTyping = true layer.textAnimator = gfx.animator.new(layer.effect.duration or 500, 0, string.len(layer.text), playdate.easingFunctions.linear, layer.effect.delay or 0) if layer.effect.playAudio ~= false then playdate.timer.performAfterDelay(layer.effect.delay or 0, startLayerTypingSound, layer) end else layer.needsRedraw = true txt = "" end end if layer.isTyping then local j = math.ceil(layer.textAnimator:currentValue()) txt = string.sub(layer.text, 1, j) if txt == layer.text then layer.isTyping = false layer.needsRedraw = false Panels.Audio.stopTypingSound() end end end end if layer.background then local w, h = 0, 0 if layer.rect then w, h = gfx.getTextSizeForMaxWidth(txt, layer.rect.width, lineHeight) else w, h = gfx.getTextSize(txt) end local borderColor = Panels.Color.BLACK gfx.setColor(layer.background) if layer.background == Panels.Color.BLACK then gfx.setImageDrawMode(gfx.kDrawModeFillWhite) borderColor = Panels.Color.WHITE end if w > 0 and h > 0 then if layer.borderRadius then gfx.fillRoundRect(0, 0, w + textMarginLeft + textMarginRight, h + textMarginTop + textMarginBottom, layer.borderRadius) else gfx.fillRect(0, 0, w + textMarginLeft + textMarginRight, h + textMarginTop + textMarginBottom) end if layer.border then local borderWidth = layer.border or 1 gfx.setColor(borderColor) gfx.setLineWidth(borderWidth) if layer.borderRadius then gfx.drawRoundRect(borderWidth * 0.5, borderWidth * 0.5, w + textMarginLeft + textMarginRight, h + textMarginTop + textMarginBottom, layer.borderRadius) else gfx.drawRect(borderWidth * 0.5, borderWidth * 0.5, w + textMarginLeft + textMarginRight, h + textMarginTop + textMarginBottom) end end end end local fillWhite = self.color == Panels.Color.WHITE if layer.color then fillWhite = layer.color == Panels.Color.WHITE end if fillWhite then gfx.setImageDrawMode(gfx.kDrawModeFillWhite) end local invertTextColor = self.invertTextColor if layer.invertTextColor ~= nil then invertTextColor = layer.invertTextColor end if invertTextColor then gfx.setImageDrawMode(gfx.kDrawModeInverted) end if layer.rect then gfx.drawTextInRect(txt, textMarginLeft, textMarginTop, layer.rect.width, layer.rect.height, lineHeight, "...", layer.alignment or Panels.TextAlignment.LEFT) else gfx.drawText(txt, textMarginLeft, textMarginTop) end gfx.popContext() end if layer.alpha and layer.alpha < 1 then layer.cachedTextImg:drawFaded(xPos - textMarginLeft, yPos - textMarginTop, layer.alpha, playdate.graphics.image.kDitherTypeBayer8x8) else layer.cachedTextImg:draw(xPos - textMarginLeft, yPos - textMarginTop) end end function panel:drawBorder(color, bgColor) local frameW = self.frame.width local frameH = self.frame.height local borderW = Panels.Settings.borderWidth local b = gfx.image.new(frameW, frameH) local matte = gfx.image.new(frameW, frameH) gfx.pushContext(matte) -- create the corner matte gfx.setColor(bgColor) gfx.setLineWidth(borderW) gfx.fillRect(0, 0, frameW, frameH) gfx.setColor(Panels.Color.invert(bgColor)) gfx.fillRoundRect(0, 0, frameW, frameH, Panels.Settings.borderRadius) gfx.popContext() gfx.pushContext(b) -- draw corner matte with center transparency if bgColor == Panels.Color.WHITE then gfx.setImageDrawMode(gfx.kDrawModeBlackTransparent) else gfx.setImageDrawMode(gfx.kDrawModeWhiteTransparent) end matte:draw(0, 0) gfx.setLineWidth(borderW) gfx.setColor(color) gfx.drawRoundRect(borderW / 2, borderW / 2, frameW - borderW, frameH - borderW, Panels.Settings.borderRadius) gfx.popContext() return b end local shouldAutoAdvance = false function panel:shouldAutoAdvance() if self.advanceFunction then return self:advanceFunction() else return self.autoAdvanceDidComplete end end function panel:killTypingEffects() if self.layers then for i, l in ipairs(self.layers) do if l.isTyping then l.isTyping = false Panels.Audio.stopTypingSound() end if l.textAnimator then l.textAnimator = nil end end end end function panel:updateAdvanceButton() if self.advanceButton.state == "hidden" then if self.advanceControlPosition and self.advanceControlPosition.delay and self.advanceControlTimer == nil then self.advanceControlTimer = playdate.timer.new(self.advanceControlPosition.delay, nil) elseif self.advanceControlPosition == nil or self.advanceControlPosition.delay == nil or (self.advanceControlTimer and self.advanceControlTimer.currentTime >= self.advanceControlTimer.duration) then if not self.advanceControlTimerDidEnd then self.advanceButton:show() self.advanceControlTimerDidEnd = true end end else if pdButtonJustPressed(self.advanceControl) then if(self.inputEnabled) then self.advanceButton:press() end end self.advanceButton:draw() end end function panel:autoAdvanceTimerComplete() if self.autoAdvanceTimerDidStart then self.autoAdvanceDidComplete = true else self.autoAdvanceTimer:remove() end end function panel:render(offset, borderColor, bgColor) local frame = self.frame self.wasOnScreen = true if self.updateFunction then self:updateFunction(offset) end if self.autoAdvance ~= nil and not self.autoAdvanceTimerDidStart then self.autoAdvanceTimerDidStart = true self.autoAdvanceTimer = playdate.timer.new(self.autoAdvance, function() self:autoAdvanceTimerComplete() end) end gfx.setDrawOffset(math.floor(offset.x + frame.x), math.floor(offset.y + frame.y)) gfx.setClipRect(0, 0, frame.width, frame.height) if self.backgroundColor then gfx.clear(self.backgroundColor) end if self.sfxPlayer then self:updatePanelAudio(offset) end if self.renderFunction then self:renderFunction(offset) else self:drawLayers(offset) end if self.choices then self.choices:render() end if not self.borderless then if self.borderImage == nil then self.borderImage = self:drawBorder(borderColor, bgColor) end self.borderImage:draw(0, 0) end if self.advanceButton then self:updateAdvanceButton() end if self.panels then local o = { x = offset.x + self.frame.x, y = offset.y + self.frame.y } if offset.x == 0 then o.x = 0 end if offset.y == 0 then o.y = 0 end for i, subPanel in ipairs(self.panels) do subPanel:render(o, borderColor, bgColor) end end -- let the frame render before disabling input -- so the button presses get rendered self.inputEnabled = self.willEnableInput or false end return panel end function table.shallow_copy(t) local t2 = {} for k, v in pairs(t) do t2[k] = v end return t2 end ================================================ FILE: modules/ScrollConstants.lua ================================================ Panels.ScrollType = { AUTO = 1, MANUAL = 2, } Panels.ScrollAxis = { VERTICAL = 1, HORIZONTAL = 2, } Panels.ScrollDirection = { TOP_DOWN = 1, TOP_TO_BOTTOM = 1, BOTTOM_UP = 2, BOTTOM_TO_TOP = 2, L_TO_R = 3, LEFT_TO_RIGHT = 3, R_TO_L = 4, RIGHT_TO_LEFT = 4, NONE = 5, } ================================================ FILE: modules/Settings.lua ================================================ Panels.Settings = { -- path settings path = "libraries/panels/", imageFolder = "images/", audioFolder = "audio/", -- project settings defaultFont = nil, defaultFontFamily = nil, menuFontFamily = nil, resetVarsOnGameOver = true, -- panel settings defaultFrame = {gap = 50, margin = 8}, snapToPanels = false, sequenceTransitionDuration = 750, borderWidth = 2, borderRadius = 2, typingSound = Panels.Audio.TypingSound.DEFAULT, maxScrollSpeed = 8, -- menu settings menuImage = "menuImage.png", listLockedSequences = true, chapterMenuHeaderImage = nil, useChapterMenu = true, showMenuOnLaunch = false, skipMenuOnFirstLaunch = false, playMenuSounds = true, showMainMenuOption = false, mainMenuOptionLabel = "Main Menu", -- credits useCreditsMenu = true, showCreditsOnGameOver = false, -- debug debugControlsEnabled = false, listUnnamedSequences = false, showFPS = false, } local function addSlashToFolderName(f) if string.sub(f, -1) ~= "/" then f = f .. "/" end return f end function validateSettings() local s = Panels.Settings s.imageFolder = addSlashToFolderName(s.imageFolder) s.audioFolder = addSlashToFolderName(s.audioFolder) s.path = addSlashToFolderName(s.path) end ================================================ FILE: modules/TextAlignment.lua ================================================ Panels.TextAlignment = { LEFT = kTextAlignment.left, RIGHT = kTextAlignment.right, CENTER = kTextAlignment.center } ================================================ FILE: modules/Utils.lua ================================================ function round(num, numDecimalPlaces) local mult = 10^(numDecimalPlaces or 0) if num >= 0 then return math.floor(num * mult + 0.5) / mult else return math.ceil(num * mult - 0.5) / mult end end function printError(error, message) if error then print("Panels: "..message) print("- "..error) end end function reverseTable(t) for i = 1, math.floor(#t/2) do local j = #t - i + 1 t[i], t[j] = t[j], t[i] end end function hasValue(tbl, value) for k, v in ipairs(tbl) do -- iterate table (for sequential tables only) if v == value or (type(v) == "table" and hasValue(v, value)) then -- Compare value from the table directly with the value we are looking for, otherwise if the value is table, check its content for this value. return true -- Found in this or nested table end end return false -- Not found end