doodle/pkg/windows/levelpack_open.go
Noah Petherbridge 48e18da511 Centralize cheats, detect cheated player character
* If the player runs the PlayAsBird cheat they shouldn't be able to win
  a high score on a level, so at level startup it detects whether the
  DefaultPlayerCharacterDoodad has changed from default on a level that
  doesn't use the Start Flag to set a specific doodad - and immediately
  marks the session as cheated
2022-01-08 18:27:37 -08:00

538 lines
12 KiB
Go

package windows
import (
"fmt"
"math"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/levelpack"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/modal"
"git.kirsle.net/apps/doodle/pkg/savegame"
"git.kirsle.net/apps/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 {
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
}