Noah Petherbridge
82884c79ae
Add the ability for the free version of the game to allow loading levels that use embedded custom doodads if those levels are signed. * Uses the same signing keys as the JWT token for license registrations. * Levels and Levelpacks can both be signed. So individual levels with embedded doodads can work in free versions of the game. * Levelpacks now support embedded doodads properly: the individual levels in the pack don't need to embed a custom doodad, but if the doodad exists in the levelpack's doodads/ folder it will load from there instead - for full versions of the game OR when the levelpack is signed. Signatures are computed by getting a listing of embedded assets inside the zipfile (the assets/ folder in levels, and the doodads/ + levels/ folders in levelpacks). Thus for individual signed levels, the level geometry and metadata may be changed without breaking the signature but if custom doodads are changed the signature will break. The doodle-admin command adds subcommands to `sign-level` and `verify-level` to manage signatures on levels and levelpacks. When using the `doodad levelpack create` command, any custom doodads the levels mention that are found in your profile directory get embedded into the zipfile by default (with --doodads custom).
539 lines
12 KiB
Go
539 lines
12 KiB
Go
package windows
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/balance"
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/levelpack"
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/modal"
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/savegame"
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/sprites"
|
|
"git.kirsle.net/go/render"
|
|
"git.kirsle.net/go/ui"
|
|
)
|
|
|
|
// LevelPack window lets the user open and play a level from a pack.
|
|
type LevelPack struct {
|
|
Supervisor *ui.Supervisor
|
|
Engine render.Engine
|
|
|
|
// Callback functions.
|
|
OnPlayLevel func(pack *levelpack.LevelPack, level levelpack.Level)
|
|
OnCloseWindow func()
|
|
|
|
// Internal variables
|
|
window *ui.Window
|
|
tabFrame *ui.TabFrame
|
|
savegame *savegame.SaveGame
|
|
goldSprite *ui.Image
|
|
silverSprite *ui.Image
|
|
}
|
|
|
|
// NewLevelPackWindow initializes the window.
|
|
func NewLevelPackWindow(config LevelPack) *ui.Window {
|
|
// Default options.
|
|
var (
|
|
title = "Select a Level"
|
|
|
|
// size of the popup window
|
|
width = 320
|
|
height = 360
|
|
)
|
|
|
|
// Get the available .levelpack files.
|
|
lpFiles, packmap, err := levelpack.LoadAllAvailable()
|
|
if err != nil {
|
|
log.Error("Couldn't list levelpack files: %s", err)
|
|
}
|
|
|
|
// Load the user's savegame.json
|
|
sg, err := savegame.GetOrCreate()
|
|
config.savegame = sg
|
|
if err != nil {
|
|
log.Warn("NewLevelPackWindow: didn't load savegame json (fresh struct created): %s", err)
|
|
}
|
|
|
|
// Cache the gold/silver sprite icons.
|
|
if goldSprite, err := sprites.LoadImage(config.Engine, "sprites/gold.png"); err == nil {
|
|
config.goldSprite = goldSprite
|
|
}
|
|
if silverSprite, err := sprites.LoadImage(config.Engine, "sprites/silver.png"); err == nil {
|
|
config.silverSprite = silverSprite
|
|
}
|
|
|
|
window := ui.NewWindow(title)
|
|
window.SetButtons(ui.CloseButton)
|
|
window.Configure(ui.Config{
|
|
Width: width,
|
|
Height: height,
|
|
Background: render.Grey,
|
|
})
|
|
config.window = window
|
|
|
|
frame := ui.NewFrame("Window Body Frame")
|
|
window.Pack(frame, ui.Pack{
|
|
Side: ui.N,
|
|
Fill: true,
|
|
Expand: true,
|
|
})
|
|
|
|
// Use a TabFrame to organize the "screens" of this window.
|
|
// The default screen is a pager for LevelPacks,
|
|
// And each LevelPack's screen is a pager for its Levels.
|
|
tabFrame := ui.NewTabFrame("Screens Manager")
|
|
tabFrame.SetTabsHidden(true)
|
|
tabFrame.Supervise(config.Supervisor)
|
|
window.Pack(tabFrame, ui.Pack{
|
|
Side: ui.N,
|
|
FillX: true,
|
|
})
|
|
config.tabFrame = tabFrame
|
|
|
|
// Make the tabs.
|
|
indexTab := tabFrame.AddTab("LevelPacks", ui.NewLabel(ui.Label{
|
|
Text: "LevelPacks",
|
|
Font: balance.TabFont,
|
|
}))
|
|
config.makeIndexScreen(indexTab, width, height, lpFiles, packmap, func(screen string) {
|
|
// Callback for user choosing a level pack.
|
|
// Hide the index screen and show the screen for this pack.
|
|
tabFrame.SetTab(screen)
|
|
})
|
|
for _, filename := range lpFiles {
|
|
tab := tabFrame.AddTab(filename, ui.NewLabel(ui.Label{
|
|
Text: filename,
|
|
Font: balance.TabFont,
|
|
}))
|
|
config.makeDetailScreen(tab, width, height, packmap[filename])
|
|
}
|
|
|
|
// Close button.
|
|
if config.OnCloseWindow != nil {
|
|
closeBtn := ui.NewButton("Close Window", ui.NewLabel(ui.Label{
|
|
Text: "Close",
|
|
Font: balance.MenuFont,
|
|
}))
|
|
closeBtn.Handle(ui.Click, func(ed ui.EventData) error {
|
|
config.OnCloseWindow()
|
|
return nil
|
|
})
|
|
config.Supervisor.Add(closeBtn)
|
|
window.Place(closeBtn, ui.Place{
|
|
Bottom: 15,
|
|
Center: true,
|
|
})
|
|
}
|
|
|
|
window.Supervise(config.Supervisor)
|
|
window.Hide()
|
|
return window
|
|
}
|
|
|
|
/*
|
|
Index screen for the LevelPack window.
|
|
|
|
frame: a TabFrame to populate
|
|
*/
|
|
func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int,
|
|
lpFiles []string, packmap map[string]*levelpack.LevelPack, onChoose func(string)) {
|
|
var (
|
|
buttonHeight = 60 // height of each LevelPack button
|
|
buttonWidth = width - 40
|
|
|
|
// pagination values
|
|
page = 1
|
|
pages int
|
|
perPage = 3
|
|
maxPageButtons = 10
|
|
)
|
|
|
|
label := ui.NewLabel(ui.Label{
|
|
Text: "Select from a Level Pack below:",
|
|
Font: balance.LabelFont,
|
|
})
|
|
frame.Pack(label, ui.Pack{
|
|
Side: ui.N,
|
|
PadX: 8,
|
|
PadY: 8,
|
|
})
|
|
|
|
pages = int(
|
|
math.Ceil(
|
|
float64(len(lpFiles)) / float64(perPage),
|
|
),
|
|
)
|
|
|
|
var buttons []*ui.Button
|
|
for i, filename := range lpFiles {
|
|
filename := filename
|
|
lp, ok := packmap[filename]
|
|
if !ok {
|
|
log.Error("Couldn't find %s in packmap!", filename)
|
|
continue
|
|
}
|
|
|
|
// Make a frame to hold a complex button layout.
|
|
btnFrame := ui.NewFrame("Frame")
|
|
btnFrame.Resize(render.Rect{
|
|
W: buttonWidth,
|
|
H: buttonHeight,
|
|
})
|
|
|
|
// Draw labels...
|
|
label := ui.NewLabel(ui.Label{
|
|
Text: lp.Title,
|
|
Font: balance.LabelFont,
|
|
})
|
|
btnFrame.Pack(label, ui.Pack{
|
|
Side: ui.N,
|
|
})
|
|
|
|
description := lp.Description
|
|
if description == "" {
|
|
description = "(No description)"
|
|
}
|
|
|
|
byline := ui.NewLabel(ui.Label{
|
|
Text: description,
|
|
Font: balance.MenuFont,
|
|
})
|
|
btnFrame.Pack(byline, ui.Pack{
|
|
Side: ui.N,
|
|
})
|
|
|
|
numLevels := ui.NewLabel(ui.Label{
|
|
Text: fmt.Sprintf("[completed %d of %d levels]", config.savegame.CountCompleted(lp.Filename), len(lp.Levels)),
|
|
Font: balance.MenuFont,
|
|
})
|
|
btnFrame.Pack(numLevels, ui.Pack{
|
|
Side: ui.N,
|
|
})
|
|
|
|
button := ui.NewButton(filename, btnFrame)
|
|
button.Handle(ui.Click, func(ed ui.EventData) error {
|
|
onChoose(filename)
|
|
return nil
|
|
})
|
|
|
|
frame.Pack(button, ui.Pack{
|
|
Side: ui.N,
|
|
PadY: 2,
|
|
})
|
|
config.Supervisor.Add(button)
|
|
|
|
if i > perPage-1 {
|
|
button.Hide()
|
|
}
|
|
buttons = append(buttons, button)
|
|
}
|
|
|
|
pager := ui.NewPager(ui.Pager{
|
|
Name: "LevelPack Pager",
|
|
Page: page,
|
|
Pages: pages,
|
|
PerPage: perPage,
|
|
MaxPageButtons: maxPageButtons,
|
|
Font: balance.MenuFont,
|
|
OnChange: func(newPage, perPage int) {
|
|
page = newPage
|
|
log.Info("Page: %d, %d", page, perPage)
|
|
|
|
// Re-evaluate which rows are shown/hidden for the page we're on.
|
|
var (
|
|
minRow = (page - 1) * perPage
|
|
visible = 0
|
|
)
|
|
for i, row := range buttons {
|
|
if visible >= perPage {
|
|
row.Hide()
|
|
continue
|
|
}
|
|
|
|
if i < minRow {
|
|
row.Hide()
|
|
} else {
|
|
row.Show()
|
|
visible++
|
|
}
|
|
}
|
|
},
|
|
})
|
|
pager.Compute(config.Engine)
|
|
pager.Supervise(config.Supervisor)
|
|
frame.Pack(pager, ui.Pack{
|
|
Side: ui.N,
|
|
PadY: 2,
|
|
})
|
|
}
|
|
|
|
// Detail screen for a given levelpack.
|
|
func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp *levelpack.LevelPack) *ui.Frame {
|
|
var (
|
|
buttonHeight = 40
|
|
buttonWidth = width - 40
|
|
|
|
page = 1
|
|
perPage = 4
|
|
pages = int(
|
|
math.Ceil(
|
|
float64(len(lp.Levels)) / float64(perPage),
|
|
),
|
|
)
|
|
maxPageButtons = 10
|
|
)
|
|
|
|
// Load the padlock icon for locked levels.
|
|
// If not loadable, won't be used in UI.
|
|
padlock, _ := sprites.LoadImage(config.Engine, balance.LockIcon)
|
|
|
|
// How many levels completed?
|
|
var (
|
|
numCompleted = config.savegame.CountCompleted(lp.Filename)
|
|
numUnlocked = lp.FreeLevels + numCompleted
|
|
)
|
|
|
|
/** Back Button */
|
|
backButton := ui.NewButton("Back", ui.NewLabel(ui.Label{
|
|
Text: "< Back",
|
|
Font: ui.MenuFont,
|
|
}))
|
|
backButton.SetStyle(&balance.ButtonBabyBlue)
|
|
backButton.Handle(ui.Click, func(ed ui.EventData) error {
|
|
config.tabFrame.SetTab("LevelPacks")
|
|
return nil
|
|
})
|
|
config.Supervisor.Add(backButton)
|
|
frame.Pack(backButton, ui.Pack{
|
|
Side: ui.NE,
|
|
PadY: 2,
|
|
PadX: 6,
|
|
})
|
|
|
|
// Spacer: the back button is position NW and the rest against N
|
|
// so may overlap.
|
|
spacer := ui.NewFrame("Spacer")
|
|
spacer.Configure(ui.Config{
|
|
Width: 64,
|
|
Height: 30,
|
|
})
|
|
frame.Pack(spacer, ui.Pack{
|
|
Side: ui.N,
|
|
})
|
|
|
|
// LevelPack Title label
|
|
label := ui.NewLabel(ui.Label{
|
|
Text: lp.Title,
|
|
Font: balance.LabelFont,
|
|
})
|
|
frame.Pack(label, ui.Pack{
|
|
Side: ui.NW,
|
|
PadX: 8,
|
|
PadY: 2,
|
|
})
|
|
|
|
// Description
|
|
if lp.Description != "" {
|
|
label := ui.NewLabel(ui.Label{
|
|
Text: lp.Description,
|
|
Font: balance.MenuFont,
|
|
})
|
|
frame.Pack(label, ui.Pack{
|
|
Side: ui.N,
|
|
PadX: 8,
|
|
PadY: 2,
|
|
})
|
|
}
|
|
|
|
// Byline
|
|
if lp.Author != "" {
|
|
label := ui.NewLabel(ui.Label{
|
|
Text: "by " + lp.Author,
|
|
Font: balance.MenuFont,
|
|
})
|
|
frame.Pack(label, ui.Pack{
|
|
Side: ui.N,
|
|
PadX: 8,
|
|
PadY: 2,
|
|
})
|
|
}
|
|
|
|
// Loop over all the levels in this pack.
|
|
var buttons []*ui.Button
|
|
for i, level := range lp.Levels {
|
|
level := level
|
|
score := config.savegame.GetLevelScore(lp.Filename, level.Filename)
|
|
|
|
// Make a frame to hold a complex button layout.
|
|
btnFrame := ui.NewFrame("Frame")
|
|
btnFrame.Resize(render.Rect{
|
|
W: buttonWidth,
|
|
H: buttonHeight,
|
|
})
|
|
|
|
// Padlock icon in the corner.
|
|
var locked = lp.FreeLevels > 0 && i+1 > numUnlocked
|
|
if locked && padlock != nil {
|
|
btnFrame.Pack(padlock, ui.Pack{
|
|
Side: ui.NE,
|
|
Padding: 4,
|
|
})
|
|
}
|
|
|
|
// Title Line
|
|
title := ui.NewLabel(ui.Label{
|
|
Text: level.Title,
|
|
Font: balance.LabelFont,
|
|
})
|
|
btnFrame.Pack(title, ui.Pack{
|
|
Side: ui.NW,
|
|
})
|
|
|
|
// Score Frame
|
|
detail := ui.NewFrame("Score")
|
|
btnFrame.Pack(detail, ui.Pack{
|
|
Side: ui.NW,
|
|
})
|
|
if score.Completed {
|
|
check := ui.NewLabel(ui.Label{
|
|
Text: "✓ Completed",
|
|
Font: balance.MenuFont,
|
|
})
|
|
detail.Pack(check, ui.Pack{
|
|
Side: ui.W,
|
|
})
|
|
|
|
// Perfect Time
|
|
if score.PerfectTime != nil {
|
|
perfFrame := ui.NewFrame("Perfect Score")
|
|
detail.Pack(perfFrame, ui.Pack{
|
|
Side: ui.W,
|
|
PadX: 8,
|
|
})
|
|
|
|
if config.goldSprite != nil {
|
|
perfFrame.Pack(config.goldSprite, ui.Pack{
|
|
Side: ui.W,
|
|
PadX: 1,
|
|
})
|
|
}
|
|
|
|
timeLabel := ui.NewLabel(ui.Label{
|
|
Text: savegame.FormatDuration(*score.PerfectTime),
|
|
Font: balance.MenuFont.Update(render.Text{
|
|
Color: render.DarkYellow,
|
|
}),
|
|
})
|
|
perfFrame.Pack(timeLabel, ui.Pack{
|
|
Side: ui.W,
|
|
})
|
|
}
|
|
|
|
// Best Time (non-perfect)
|
|
if score.BestTime != nil {
|
|
bestFrame := ui.NewFrame("Best Score")
|
|
detail.Pack(bestFrame, ui.Pack{
|
|
Side: ui.W,
|
|
PadX: 4,
|
|
})
|
|
|
|
if config.silverSprite != nil {
|
|
bestFrame.Pack(config.silverSprite, ui.Pack{
|
|
Side: ui.W,
|
|
PadX: 1,
|
|
})
|
|
}
|
|
|
|
timeLabel := ui.NewLabel(ui.Label{
|
|
Text: savegame.FormatDuration(*score.BestTime),
|
|
Font: balance.MenuFont.Update(render.Text{
|
|
Color: render.DarkGreen,
|
|
}),
|
|
})
|
|
bestFrame.Pack(timeLabel, ui.Pack{
|
|
Side: ui.W,
|
|
})
|
|
}
|
|
} else {
|
|
detail.Pack(ui.NewLabel(ui.Label{
|
|
Text: "Not completed",
|
|
Font: balance.MenuFont,
|
|
}), ui.Pack{
|
|
Side: ui.W,
|
|
})
|
|
}
|
|
|
|
btn := ui.NewButton(level.Filename, btnFrame)
|
|
btn.Handle(ui.Click, func(ed ui.EventData) error {
|
|
// Is this level locked?
|
|
if locked && !balance.CheatEnabledUnlockLevels {
|
|
modal.Alert(
|
|
"This level hasn't been unlocked! Complete the earlier\n" +
|
|
"levels in this pack to unlock later levels.",
|
|
).WithTitle("Locked Level")
|
|
return nil
|
|
}
|
|
|
|
// Play Level
|
|
if config.OnPlayLevel != nil {
|
|
config.OnPlayLevel(lp, level)
|
|
} else {
|
|
log.Error("LevelPack Window: OnPlayLevel callback not ready")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
frame.Pack(btn, ui.Pack{
|
|
Side: ui.N,
|
|
PadY: 2,
|
|
})
|
|
config.Supervisor.Add(btn)
|
|
|
|
if i > perPage-1 {
|
|
btn.Hide()
|
|
}
|
|
buttons = append(buttons, btn)
|
|
}
|
|
|
|
pager := ui.NewPager(ui.Pager{
|
|
Name: "Level Pager",
|
|
Page: page,
|
|
Pages: pages,
|
|
PerPage: perPage,
|
|
MaxPageButtons: maxPageButtons,
|
|
Font: balance.MenuFont,
|
|
OnChange: func(newPage, perPage int) {
|
|
page = newPage
|
|
log.Info("Page: %d, %d", page, perPage)
|
|
|
|
// Re-evaluate which rows are shown/hidden for the page we're on.
|
|
var (
|
|
minRow = (page - 1) * perPage
|
|
visible = 0
|
|
)
|
|
for i, row := range buttons {
|
|
if visible >= perPage {
|
|
row.Hide()
|
|
continue
|
|
}
|
|
|
|
if i < minRow {
|
|
row.Hide()
|
|
} else {
|
|
row.Show()
|
|
visible++
|
|
}
|
|
}
|
|
},
|
|
})
|
|
pager.Compute(config.Engine)
|
|
pager.Supervise(config.Supervisor)
|
|
frame.Pack(pager, ui.Pack{
|
|
Side: ui.N,
|
|
PadY: 2,
|
|
})
|
|
|
|
return frame
|
|
}
|