Full Code of cadin/panels for AI

main 12aa510d41ff cached
22 files
155.2 KB
51.6k tokens
1 requests
Download .txt
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.

![Banner](./assets/images/panelsBanner.gif)

Provide Panels with a Lua table that describes the sequences in your comic (scroll direction, panel sizes, text, animation and effects) along with your layered graphics. Panels will handle layout, scrolling, animation, and even chapter navigation for you.

Comics built with Panels can support these features:

-   layered, parallax scrolling
-   nested panels
-   sequences with different scroll directions
-   manual (crank) scrolling and auto advancing (panel-by-panel)
-   panel effects like shake and blink
-   animated transitions between sequences
-   animations and transitions within panels based on scroll position
-   animated text layers
-   panels with fully custom render functions
-   branching "choose-your-own-adventure" storylines

## Documentation
Check out the full set of documentation here:
### [📄 Panels Documentation](//cadin.github.io/panels)
### [📺 Tutorial Videos](https://www.youtube.com/playlist?list=PLvk_cJkKCihbN4Q61lopDtSQMbx4vNLvv)

## Requirements

-   [Playdate SDK](https://play.date/dev/)
-   [Playdate Console](https://shop.play.date) (optional)

## Setup

### From Template Project

1. Clone the [Panels Project Template](https://github.com/cadin/panels-project-template).
    This is a [Template Repo](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template). Click "Use this template" to create your own fresh repo with all the contents of the project template.
2. The template project includes the Panels framework as a git submodule. Be sure to properly [initialize the submodule](https://www.w3docs.com/snippets/git/how-to-clone-including-submodules.html) when cloning the repo.
3. Start editing table in `myComicData.lua`.

### Manual Setup

1. Clone this repo into your project into a `libraries` folder.
2. Inside your `main.lua` file import Panels.
3. Create or import your [`comicData`](http://cadin.github.io/panels/docs/comic-data) table.
4. Start Panels with your `comicData` table as the sole argument.

### Example `main.lua` File:

```lua
import "libraries/panels/Panels"
local comicData = {
    -- comic data goes here...
}
Panels.start(comicData)
```

## Support
### Get Help
- 📺 Watch these **[Tutorial Videos](https://www.youtube.com/playlist?list=PLvk_cJkKCihbN4Q61lopDtSQMbx4vNLvv)** to get up to speed quickly.  
- 🤖 Chat with the **[Panels Partner custom GPT](https://chat.openai.com/g/g-QU76MOCLl-panels-partner)** to get answers to questions about your specific project.
- 💬 Post your question in the **[Playdate Squad Discord](https://discord.com/channels/675983554655551509/1163630567393341461)**.



### Feature Requests

Add feature requests to the [Issues](https://github.com/cadin/panels/issues) page.

Include a description of the general functionality you need, along with your preferred implementation (if you have one). Please search first to see if someone else has already created an issue for your feature. If so, you can add a vote or comment to show your support.

### Bug Reports

File bug reports on the [Issues](https://github.com/cadin/panels/issues) page.

Each bug should be listed as a separate issue. Please search first to see if someone else has already filed the bug, and list all steps needed to reproduce the issue in the smallest possible project.

### Contribute

If you would like to contribute a feature or bug fix please contact me first and let me know which issue you want work on. If there isn't yet an issue for your proposed change, go ahead and write one.

## License

Panels is licensed under a [Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/).

**TLDR:** You can use this code (or modified versions) to create anything you want, public or private, free or commercial. For attribution, please retain the Panels credit (with URL and QR code) on the Credits page of your game so that others may find their way here.

---

👨🏻‍🦲❤️🛠


================================================
FILE: assets/fonts/Asheville-Narrow-14-Bold.fnt
================================================
--metrics={"baseline":0,"xHeight":0,"capHeight":0,"left":["BDEFHIKLMNPRbhkl","GO","aceo","mnr"],"right":["DO","HIMNdl","JU","gy"],"pairs":{"Fa":[-1,1],"Fc":[-1,1],"Fe":[-1,1],"Fo":[-1,1],"Fm":[-1,1],"Fn":[-1,1],"Fr":[-1,1],"Ta":[-3,1],"Tc":[-3,1],"Te":[-3,1],"To":[-3,1],"Tm":[-3,1],"Tn":[-3,1],"Tr":[-3,1],"Dj":[-2,2],"Oj":[-2,2],"Hj":[-2,2],"Ij":[-2,2],"Mj":[-2,2],"Nj":[-2,2],"dj":[-2,2],"lj":[-2,2],"Jj":[-2,2],"Uj":[-2,2],"gT":[-3,2],"yT":[-3,2],"AT":[-2,0],"AV":[-1,0],"AW":[-1,0],"AY":[-1,0],"Af":[-1,0],"Aj":[-2,0],"At":[-1,0],"BT":[-1,0],"BV":[-1,0],"BW":[-1,0],"BY":[-1,0],"Bf":[-1,0],"Bj":[-2,0],"Bt":[-1,0],"Cj":[-2,0],"Ef":[-1,0],"Ej":[-2,0],"Et":[-1,0],"Ev":[-1,0],"FA":[-1,0],"FJ":[-4,0],"Fd":[-1,0],"Ff":[-1,0],"Fg":[-1,0],"Fj":[-2,0],"Fp":[-1,0],"Fq":[-1,0],"Fs":[-1,0],"Ft":[-1,0],"Fu":[-1,0],"Fv":[-1,0],"Fw":[-1,0],"Fx":[-1,0],"Fy":[-1,0],"Fz":[-1,0],"Gj":[-2,0],"Kf":[-1,0],"Kj":[-2,0],"Kt":[-1,0],"Kv":[-1,0],"LT":[-3,0],"LV":[-2,0],"LW":[-2,0],"LY":[-2,0],"Lf":[-1,0],"Lj":[-2,0],"Lt":[-1,0],"Lv":[-1,0],"PA":[-1,0],"PJ":[-5,0],"Pj":[-2,0],"Rj":[-2,0],"Sj":[-2,0],"TA":[-2,0],"TJ":[-3,0],"Td":[-3,0],"Tf":[-1,0],"Tg":[-3,0],"Tj":[-2,0],"Tp":[-3,0],"Tq":[-3,0],"Ts":[-3,0],"Tt":[-1,0],"Tu":[-3,0],"Tv":[-3,0],"Tw":[-3,0],"Tx":[-3,0],"Ty":[-3,0],"Tz":[-3,0],"VA":[-1,0],"VJ":[-2,0],"Vj":[-2,0],"WA":[-1,0],"WJ":[-1,0],"Wj":[-2,0],"Xf":[-1,0],"Xj":[-2,0],"Xt":[-1,0],"Xv":[-1,0],"YA":[-1,0],"YJ":[-2,0],"Yj":[-2,0],"Zj":[-2,0],"aT":[-3,0],"aj":[-2,0],"bT":[-3,0],"bj":[-2,0],"cT":[-3,0],"cj":[-2,0],"eT":[-3,0],"ej":[-2,0],"fA":[-1,0],"fJ":[-2,0],"fj":[-2,0],"hT":[-3,0],"hj":[-2,0],"ij":[-2,0],"kT":[-3,0],"kj":[-2,0],"mT":[-3,0],"mj":[-2,0],"nT":[-3,0],"nj":[-2,0],"oT":[-3,0],"oj":[-2,0],"pT":[-3,0],"pj":[-2,0],"qT":[-3,0],"rA":[-1,0],"rJ":[-3,0],"rT":[-3,0],"rX":[-1,0],"rZ":[-2,0],"rj":[-2,0],"sT":[-3,0],"sj":[-2,0],"tA":[-1,0],"tJ":[-1,0],"tT":[-1,0],"tX":[-1,0],"tZ":[-1,0],"tj":[-2,0],"uT":[-3,0],"uj":[-2,0],"vT":[-3,0],"vX":[-1,0],"vj":[-2,0],"wT":[-3,0],"wj":[-2,0],"xT":[-3,0],"xj":[-2,0],"zT":[-3,0],"zj":[-2,0]}}
datalen=20956
data=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
width=20
height=20

tracking=1

0	8
1	3
2	7
3	7
4	7
5	9
6	8
7	7
8	8
9	8
space	3
!	2
"	5
#	9
$	8
%	12
&	11
'	3
(	5
)	5
*	8
+	6
,	3
-	6
.	2
/	6
:	2
;	2
<	6
=	6
>	6
?	7
@	10
A	8
B	8
C	7
D	8
E	6
F	6
G	8
H	7
I	2
J	7
K	8
L	6
M	11
N	8
O	8
P	8
Q	8
R	7
S	7
T	8
U	8
V	8
W	12
X	8
Y	6
Z	6
[	3
\	6
]	3
^	6
_	7
`	3
a	7
b	7
c	7
d	7
e	7
f	5
g	7
h	7
i	2
j	4
k	7
l	2
m	10
n	7
o	7
p	7
q	7
r	5
s	6
t	4
u	7
v	6
w	10
x	7
y	7
z	6
{	5
|	2
}	5
~	8
…	8
¥	8
‼	5
™	8
©	11
®	11
。	16
、	16
ぁ	16
あ	16
ぃ	16
い	16
ぅ	16
う	16
ぇ	16
え	16
ぉ	16
お	16
か	16
が	16
き	16
ぎ	16
く	16
ぐ	16
け	16
げ	16
こ	16
ご	16
さ	16
ざ	16
し	16
じ	16
す	16
ず	16
せ	16
ぜ	16
そ	16
ぞ	16
た	16
だ	16
ち	16
ぢ	16
っ	16
つ	16
づ	16
て	16
で	16
と	16
ど	16
な	16
に	16
ぬ	16
ね	16
の	16
は	16
ば	16
ぱ	16
ひ	16
び	16
ぴ	16
ふ	16
ぶ	16
ぷ	16
へ	16
べ	16
ぺ	16
ほ	16
ぼ	16
ぽ	16
ま	16
み	16
む	16
め	16
も	16
ゃ	16
や	16
ゅ	16
ゆ	16
ょ	16
よ	16
ら	16
り	16
る	16
れ	16
ろ	16
ゎ	16
わ	16
ゐ	16
ゑ	16
を	16
ん	16
ゔ	16
ゕ	16
ゖ	16
゛	1
゜	0
ゝ	16
ゞ	16
ゟ	16
゠	16
ァ	16
ア	16
ィ	16
イ	16
ゥ	16
ウ	16
ェ	16
エ	16
ォ	16
オ	16
カ	16
ガ	16
キ	16
ギ	16
ク	16
グ	16
ケ	16
ゲ	16
コ	16
ゴ	16
サ	16
ザ	16
シ	16
ジ	16
ス	16
ズ	16
セ	16
ゼ	16
ソ	16
ゾ	16
タ	16
ダ	16
チ	16
ヂ	16
ッ	16
ツ	16
ヅ	16
テ	16
デ	16
ト	16
ド	16
ナ	16
ニ	16
ヌ	16
ネ	16
ノ	16
ハ	16
バ	16
パ	16
ヒ	16
ビ	16
ピ	16
フ	16
ブ	16
プ	16
ヘ	16
ベ	16
ペ	16
ホ	16
ボ	16
ポ	16
マ	16
ミ	16
ム	16
メ	16
モ	16
ャ	16
ヤ	16
ュ	16
ユ	16
ョ	16
ヨ	16
ラ	16
リ	16
ル	16
レ	16
ロ	16
ヮ	16
ワ	16
ヰ	16
ヱ	16
ヲ	16
ン	16
ヴ	16
ヵ	16
ヶ	16
ヷ	16
ヸ	16
ヹ	16
ヺ	16
・	16
ー	16
ヽ	16
ヾ	16
ヿ	16
「	16
」	16
円	16
�	13

Fa	-1
Fc	-1
Fe	-1
Fo	-1
Fm	-1
Fn	-1
Fr	-1
Ta	-3
Tc	-3
Te	-3
To	-3
Tm	-3
Tn	-3
Tr	-3
Dj	-2
Oj	-2
Hj	-2
Ij	-2
Mj	-2
Nj	-2
dj	-2
lj	-2
Jj	-2
Uj	-2
gT	-3
yT	-3
AT	-2
AV	-1
AW	-1
AY	-1
Af	-1
Aj	-2
At	-1
BT	-1
BV	-1
BW	-1
BY	-1
Bf	-1
Bj	-2
Bt	-1
Cj	-2
Ef	-1
Ej	-2
Et	-1
Ev	-1
FA	-1
FJ	-4
Fd	-1
Ff	-1
Fg	-1
Fj	-2
Fp	-1
Fq	-1
Fs	-1
Ft	-1
Fu	-1
Fv	-1
Fw	-1
Fx	-1
Fy	-1
Fz	-1
Gj	-2
Kf	-1
Kj	-2
Kt	-1
Kv	-1
LT	-3
LV	-2
LW	-2
LY	-2
Lf	-1
Lj	-2
Lt	-1
Lv	-1
PA	-1
PJ	-5
Pj	-2
Rj	-2
Sj	-2
TA	-2
TJ	-3
Td	-3
Tf	-1
Tg	-3
Tj	-2
Tp	-3
Tq	-3
Ts	-3
Tt	-1
Tu	-3
Tv	-3
Tw	-3
Tx	-3
Ty	-3
Tz	-3
VA	-1
VJ	-2
Vj	-2
WA	-1
WJ	-1
Wj	-2
Xf	-1
Xj	-2
Xt	-1
Xv	-1
YA	-1
YJ	-2
Yj	-2
Zj	-2
aT	-3
aj	-2
bT	-3
bj	-2
cT	-3
cj	-2
eT	-3
ej	-2
fA	-1
fJ	-2
fj	-2
hT	-3
hj	-2
ij	-2
kT	-3
kj	-2
mT	-3
mj	-2
nT	-3
nj	-2
oT	-3
oj	-2
pT	-3
pj	-2
qT	-3
rA	-1
rJ	-3
rT	-3
rX	-1
rZ	-2
rj	-2
sT	-3
sj	-2
tA	-1
tJ	-1
tT	-1
tX	-1
tZ	-1
tj	-2
uT	-3
uj	-2
vT	-3
vX	-1
vj	-2
wT	-3
wj	-2
xT	-3
xj	-2
zT	-3
zj	-2


================================================
FILE: modules/Alert.lua
================================================
local gfx <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
Download .txt
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![Banner](./assets/images/panelsBanner.gif)\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.

Copied to clipboard!