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 <const> = playdate.graphics
local ScreenHeight <const> = playdate.display.getHeight()
local ScreenWidth <const> = 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.

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 <const> = playdate.graphics
local ScreenWidth <const> = playdate.display.getWidth()
local ScreenHeight <const> = 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 <const> = playdate.display.getWidth()
local ScreenHeight <const> = playdate.display.getHeight()
local gfx <const> = 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<const> = 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 <const> = playdate.graphics
local ScreenWidth <const> = playdate.display.getWidth()
local ScreenHeight <const> = 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 <const> = playdate.graphics
local ScreenHeight <const> = playdate.display.getHeight()
local ScreenWidth <const> = 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 <const> = playdate.graphics
local ScreenWidth <const> = playdate.display.getWidth()
local ScreenHeight <const> = 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 <const> = 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 <const> = playdate.graphics
local ScreenHeight <const> = playdate.display.getHeight()
local ScreenWidth <const> = 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<const> = 4
local textMarginVertical<const> = 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
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
Condensed preview — 22 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (174K chars).
[
{
"path": ".gitignore",
"chars": 9,
"preview": ".DS_Store"
},
{
"path": "LICENSE",
"chars": 18653,
"preview": "Attribution 4.0 International\n\n=======================================================================\n\nCreative Commons"
},
{
"path": "Panels.lua",
"chars": 34300,
"preview": "-- Panels version 2.2\n-- https://cadin.github.io/panels/\n\nimport \"CoreLibs/object\"\nimport \"CoreLibs/graphics\"\nimport \"Co"
},
{
"path": "README.md",
"chars": 4052,
"preview": "# Panels\n\nBuild interactive comics for the Playdate console.\n\n\n\nProvide Panel"
},
{
"path": "assets/fonts/Asheville-Narrow-14-Bold.fnt",
"chars": 25392,
"preview": "--metrics={\"baseline\":0,\"xHeight\":0,\"capHeight\":0,\"left\":[\"BDEFHIKLMNPRbhkl\",\"GO\",\"aceo\",\"mnr\"],\"right\":[\"DO\",\"HIMNdl\",\""
},
{
"path": "modules/Alert.lua",
"chars": 6302,
"preview": "local gfx <const> = playdate.graphics\nlocal ScreenWidth <const> = playdate.display.getWidth()\nlocal ScreenHeight <const>"
},
{
"path": "modules/Audio.lua",
"chars": 3414,
"preview": "local bgAudioPlayer = nil\nlocal shouldResume = false\nlocal repeatCount = 1\nlocal typingRetainCount = 0\nlocal typingSampl"
},
{
"path": "modules/ButtonIndicator.lua",
"chars": 3458,
"preview": "Panels.ButtonIndicator = {}\n\nlocal ScreenWidth <const> = playdate.display.getWidth()\nlocal ScreenHeight <const> = playda"
},
{
"path": "modules/ChoiceList.lua",
"chars": 4539,
"preview": "\nlocal gfx<const> = playdate.graphics\nPanels.ChoiceList = {}\n\nlocal function renderChoiceButton(text, x, y, w, h, radius"
},
{
"path": "modules/Color.lua",
"chars": 283,
"preview": "Panels.Color = {\n\tWHITE = playdate.graphics.kColorWhite,\n\tBLACK = playdate.graphics.kColorBlack,\n\tCLEAR = playdate.graph"
},
{
"path": "modules/Credits.lua",
"chars": 6329,
"preview": "local gfx <const> = playdate.graphics\nlocal ScreenWidth <const> = playdate.display.getWidth()\nlocal ScreenHeight <const>"
},
{
"path": "modules/Effect.lua",
"chars": 118,
"preview": "Panels.Effect = {\n\tSHAKE_UNISON = 1,\n\tSHAKE_INDIVIDUAL = 2,\n\tBLINK = 3,\n\tTYPE_ON = 4,\n\n\tSHAKE = 2,\n\tSHAKE_LAYER = 2,\n}"
},
{
"path": "modules/Font.lua",
"chars": 1055,
"preview": "Panels.Font = {\n\tNORMAL = playdate.graphics.font.kVariantNormal,\n\tBOLD = playdate.graphics.font.kVariantBold,\n\tITALIC = "
},
{
"path": "modules/Image.lua",
"chars": 266,
"preview": "Panels.Image = { }\n\nlocal cache = {}\nfunction Panels.Image.get(path)\n local error = nil\n\tif cache[path] == nil then\n\t"
},
{
"path": "modules/Input.lua",
"chars": 183,
"preview": "Panels.Input = {\n\tA = playdate.kButtonA,\n\tB = playdate.kButtonB,\n\tUP = playdate.kButtonUp,\n\tDOWN = playdate.kButtonDown,"
},
{
"path": "modules/Layer.lua",
"chars": 5297,
"preview": "local gfx <const> = playdate.graphics\nlocal ScreenHeight <const> = playdate.display.getHeight()\nlocal ScreenWidth <const"
},
{
"path": "modules/Menus.lua",
"chars": 13529,
"preview": "import 'CoreLibs/ui/gridview.lua'\n\nlocal gfx <const> = playdate.graphics\n\nlocal ScreenWidth <const> = playdate.display.g"
},
{
"path": "modules/Panel.lua",
"chars": 29267,
"preview": "Panels.Panel = {}\n\nlocal gfx <const> = playdate.graphics\nlocal ScreenHeight <const> = playdate.display.getHeight()\nlocal"
},
{
"path": "modules/ScrollConstants.lua",
"chars": 287,
"preview": "Panels.ScrollType = {\n\tAUTO = 1,\n\tMANUAL = 2,\n}\n\nPanels.ScrollAxis = {\n\tVERTICAL = 1,\n\tHORIZONTAL = 2,\n}\n\nPanels.ScrollD"
},
{
"path": "modules/Settings.lua",
"chars": 1225,
"preview": "Panels.Settings = {\n\t-- path settings\n\tpath = \"libraries/panels/\",\n\timageFolder = \"images/\",\n\taudioFolder = \"audio/\",\n\t\n"
},
{
"path": "modules/TextAlignment.lua",
"chars": 118,
"preview": "Panels.TextAlignment = {\n\tLEFT = kTextAlignment.left,\n\tRIGHT = kTextAlignment.right,\n\tCENTER = kTextAlignment.center\n}"
},
{
"path": "modules/Utils.lua",
"chars": 861,
"preview": "function round(num, numDecimalPlaces)\n\tlocal mult = 10^(numDecimalPlaces or 0)\n\tif num >= 0 then return math.floor(num *"
}
]
About this extraction
This page contains the full source code of the cadin/panels GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 22 files (155.2 KB), approximately 51.6k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.