doodle/pkg/main_scene.go
Noah Petherbridge 82884c79ae Signed Levels and Levelpacks
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).
2023-02-18 17:37:54 -08:00

701 lines
17 KiB
Go

package doodle
import (
"fmt"
"math/rand"
"git.kirsle.net/SketchyMaze/doodle/pkg/balance"
"git.kirsle.net/SketchyMaze/doodle/pkg/branding"
"git.kirsle.net/SketchyMaze/doodle/pkg/level"
"git.kirsle.net/SketchyMaze/doodle/pkg/levelpack"
"git.kirsle.net/SketchyMaze/doodle/pkg/license"
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen"
"git.kirsle.net/SketchyMaze/doodle/pkg/native"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting"
"git.kirsle.net/SketchyMaze/doodle/pkg/shmem"
"git.kirsle.net/SketchyMaze/doodle/pkg/uix"
"git.kirsle.net/SketchyMaze/doodle/pkg/updater"
"git.kirsle.net/SketchyMaze/doodle/pkg/windows"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
"git.kirsle.net/go/ui/style"
)
// MainScene implements the main menu of Doodle.
type MainScene struct {
Supervisor *ui.Supervisor
LevelFilename string // custom level filename to load in background
// Background wallpaper canvas.
scripting *scripting.Supervisor
canvas *uix.Canvas
// UI components.
labelTitle *ui.Label
labelSubtitle *ui.Label
labelVersion *ui.Label
labelHint *ui.Label
frame *ui.Frame // Main button frame
winRegister *ui.Window
winSettings *ui.Window
winLevelPacks *ui.Window
// Update check variables.
updateButton *ui.Button
updateInfo updater.VersionInfo
// Lazy scroll variables. See LoopLazyScroll().
PauseLazyScroll bool // exported for dev console
lazyScrollBounce bool
lazyScrollTrajectory render.Point
lazyScrollLastValue render.Point
// Landscape mode: if the screen isn't tall enough to see the main
// menu we redo the layout to be landscape friendly. NOTE: this only
// happens one time, and does not re-adapt when the window is made
// tall enough again.
landscapeMode bool
// Debug F3 overlay vars
debLoadingViewport *string
}
/*
MakePhotogenic tweaks some variables to make a screenshotable title screen.
This function is designed to be called from the developer shell:
$ d.Scene.MakePhotogenic(true)
It automates the pausing of lazy scroll and hiding of UI elements except
for just the title and version number.
*/
func (s *MainScene) MakePhotogenic(v bool) {
if v {
s.PauseLazyScroll = true
s.ButtonFrame().Hide()
s.LabelHint().Hide()
} else {
s.PauseLazyScroll = false
s.ButtonFrame().Show()
s.LabelHint().Show()
}
}
// Name of the scene.
func (s *MainScene) Name() string {
return "Main"
}
// Setup the scene.
func (s *MainScene) Setup(d *Doodle) error {
s.debLoadingViewport = new(string)
customDebugLabels = []debugLabel{
{"Chunks:", s.debLoadingViewport},
}
s.Supervisor = ui.NewSupervisor()
if err := s.SetupDemoLevel(d); err != nil {
return err
}
// Main title label
s.labelTitle = ui.NewLabel(ui.Label{
Text: branding.AppName,
Font: balance.TitleScreenFont,
})
s.labelTitle.Compute(d.Engine)
// Subtitle/byline.
s.labelSubtitle = ui.NewLabel(ui.Label{
Text: branding.Byline,
Font: balance.TitleScreenSubtitleFont,
})
s.labelSubtitle.Compute(d.Engine)
// Version label.
var shareware string
if !license.IsRegistered() {
shareware = " (shareware)"
}
ver := ui.NewLabel(ui.Label{
Text: fmt.Sprintf("v%s%s", branding.Version, shareware),
Font: balance.TitleScreenVersionFont,
})
ver.Compute(d.Engine)
s.labelVersion = ver
// Arrow Keys hint label (scroll the demo level).
s.labelHint = ui.NewLabel(ui.Label{
Text: "Hint: press the Arrow keys",
Font: render.Text{
Size: 16,
Color: render.Grey,
Shadow: render.Purple,
},
})
s.labelHint.Compute(d.Engine)
// "Update Available" button.
s.updateButton = ui.NewButton("Update Button", ui.NewLabel(ui.Label{
Text: "An update is available!",
Font: render.Text{
FontFilename: balance.SansBoldFont,
Size: 16,
Color: render.Blue,
Padding: 4,
},
}))
s.updateButton.Handle(ui.Click, func(ed ui.EventData) error {
native.OpenURL(s.updateInfo.DownloadURL)
return nil
})
s.updateButton.Compute(d.Engine)
s.updateButton.Hide()
s.Supervisor.Add(s.updateButton)
// Main UI button frame.
frame := ui.NewFrame("frame")
s.frame = frame
var buttons = []struct {
Name string
If func() bool
Func func()
Style *style.Button
}{
{
Name: "Story Mode",
Func: func() {
if s.winLevelPacks == nil {
s.winLevelPacks = windows.NewLevelPackWindow(windows.LevelPack{
Supervisor: s.Supervisor,
Engine: d.Engine,
OnPlayLevel: func(lp *levelpack.LevelPack, which levelpack.Level) {
if err := d.PlayFromLevelpack(lp, which); err != nil {
shmem.FlashError(err.Error())
}
},
OnCloseWindow: func() {
s.winLevelPacks.Destroy()
s.winLevelPacks = nil
},
})
}
s.winLevelPacks.MoveTo(render.Point{
X: (d.width / 2) - (s.winLevelPacks.Size().W / 2),
Y: (d.height / 2) - (s.winLevelPacks.Size().H / 2),
})
s.winLevelPacks.Show()
},
Style: &balance.ButtonBabyBlue,
},
{
Name: "Play a Level",
Func: d.GotoPlayMenu,
Style: &balance.ButtonBabyBlue,
},
{
Name: "New Drawing",
Func: d.GotoNewMenu,
Style: &balance.ButtonPink,
},
{
Name: "Edit Drawing",
Func: d.GotoLoadMenu,
Style: &balance.ButtonPink,
},
{
Name: "Settings",
Func: func() {
if s.winSettings == nil {
s.winSettings = d.MakeSettingsWindow(s.Supervisor)
}
s.winSettings.Show()
},
},
{
Name: "Register",
If: func() bool {
return !license.IsRegistered()
},
Func: func() {
if s.winRegister == nil {
cfg := windows.License{
Supervisor: s.Supervisor,
Engine: d.Engine,
OnCancel: func() {
s.winRegister.Hide()
},
}
cfg.OnLicensed = func() {
// License status has changed, reload the window!
if s.winRegister != nil {
s.winRegister.Hide()
}
s.winRegister = windows.MakeLicenseWindow(d.width, d.height, cfg)
}
cfg.OnLicensed()
}
s.winRegister.Show()
},
Style: &balance.ButtonPrimary,
},
}
for _, button := range buttons {
if check := button.If; check != nil && !check() {
continue
}
button := button
btn := ui.NewButton(button.Name, ui.NewLabel(ui.Label{
Text: button.Name,
Font: balance.StatusFont,
}))
btn.Handle(ui.Click, func(ed ui.EventData) error {
button.Func()
return nil
})
if button.Style != nil {
btn.SetStyle(button.Style)
}
s.Supervisor.Add(btn)
frame.Pack(btn, ui.Pack{
Side: ui.N,
PadY: 8,
// Fill: true,
FillX: true,
})
}
// Check for update in the background.
go s.checkUpdate()
// Eager load the level in background, no time for load screen.
go func() {
if err := s.setupAsync(d); err != nil {
log.Error("MainScene.setupAsync: %s", err)
}
}()
// Trigger our "Window Resized" function so we can check if the
// layout needs to be switched to landscape mode for mobile.
s.Resized(d.width, d.height)
return nil
}
// setupAsync runs background tasks from setup, e.g. eager load
// chunks of the level for cache.
func (s *MainScene) setupAsync(d *Doodle) error {
loadscreen.PreloadAllChunkBitmaps(s.canvas.Chunker())
return nil
}
// checkUpdate checks for a version update and shows the button.
func (s *MainScene) checkUpdate() {
if shmem.OfflineMode {
log.Info("OfflineMode: skip updates check")
return
}
info, err := updater.Check()
if err != nil {
log.Error(err.Error())
return
}
if info.IsNewerVersionThan(branding.Version) {
s.updateInfo = info
s.updateButton.Show()
}
}
// SetupDemoLevel configures the wallpaper behind the New screen,
// which demos a title screen demo level.
func (s *MainScene) SetupDemoLevel(d *Doodle) error {
// Set up the background wallpaper canvas.
s.canvas = uix.NewCanvas(100, false)
s.canvas.Scrollable = true
s.canvas.Resize(render.Rect{
W: d.width,
H: d.height,
})
s.scripting = scripting.NewSupervisor()
s.canvas.SetScriptSupervisor(s.scripting)
// Title screen level to load. Pick a random level.
var (
levelName = balance.DemoLevelName[0]
fromLevelPack = true
lvl *level.Level
)
if s.LevelFilename != "" {
// User provided a custom level name, nix the demo levelpack.
levelName = s.LevelFilename
fromLevelPack = false
} else if len(balance.DemoLevelName) > 1 {
randIndex := rand.Intn(len(balance.DemoLevelName))
levelName = balance.DemoLevelName[randIndex]
}
// Get the level from the DemoLevelPack?
if fromLevelPack {
log.Debug("Initializing titlescreen from DemoLevelPack: %s", balance.DemoLevelPack)
lp, err := levelpack.LoadFile(balance.DemoLevelPack)
if err != nil {
log.Error("Error loading DemoLevelPack(%s): %s", balance.DemoLevelPack, err)
} else {
log.Debug("Loading selected level from pack: %s", levelName)
levelbin, err := lp.GetFile("levels/" + levelName)
if err != nil {
log.Error("Error getting level from DemoLevelpack(%s#%s): %s",
balance.DemoLevelPack,
levelName,
err,
)
} else {
log.Debug("Parsing loaded level data (%d bytes)", len(levelbin))
lvl, err = level.FromJSON(levelName, levelbin)
if err != nil {
log.Error("DemoLevelPack FromJSON(%s): %s", levelName, err)
lvl = nil
}
}
}
}
// May be a user-provided level.
if lvl == nil {
if trylvl, err := level.LoadFile(levelName); err == nil {
lvl = trylvl
} else {
log.Error("Error loading demo level %s: %s", balance.DemoLevelName, err)
}
}
// If still no level, initialize a basic notebook background.
if lvl != nil {
s.canvas.LoadLevel(lvl)
s.canvas.InstallActors(lvl.Actors)
// Load all actor scripts.
if err := s.scripting.InstallScripts(lvl); err != nil {
log.Error("Error with title screen level scripts: %s", err)
}
// Run all actors scripts main function to start them off.
if err := s.canvas.InstallScripts(); err != nil {
log.Error("Error running actor main() functions: %s", err)
}
} else {
// Create a basic notebook level.
s.canvas.LoadLevel(&level.Level{
Chunker: level.NewChunker(100),
Palette: level.NewPalette(),
PageType: level.Bounded,
MaxWidth: 42,
MaxHeight: 42,
Wallpaper: "notebook.png",
})
}
return nil
}
// Loop the editor scene.
func (s *MainScene) Loop(d *Doodle, ev *event.State) error {
s.Supervisor.Loop(ev)
inside, outside := s.canvas.LoadUnloadMetrics()
*s.debLoadingViewport = fmt.Sprintf("%d in %d out", inside, outside)
if err := s.scripting.Loop(); err != nil {
log.Error("MainScene.Loop: scripting.Loop: %s", err)
}
// Lazily scroll the canvas around, slowly.
s.LoopLazyScroll()
s.canvas.Loop(ev)
if ev.WindowResized {
s.Resized(d.width, d.height)
}
return nil
}
// Resized the app window.
func (s *MainScene) Resized(width, height int) {
log.Info("Resized to %dx%d", width, height)
// If the height is not tall enough for the menu, switch to the horizontal layout.
if height < balance.TitleScreenResponsiveHeight {
log.Error("Switch to landscape mode")
s.landscapeMode = true
} else {
s.landscapeMode = false
}
s.canvas.Resize(render.Rect{
W: width,
H: height,
})
}
// ButtonFrame returns the main button frame.
func (s *MainScene) ButtonFrame() *ui.Frame {
return s.frame
}
// LabelVersion returns the version widget.
func (s *MainScene) LabelVersion() *ui.Label {
return s.labelVersion
}
// LabelHint returns the hint widget.
func (s *MainScene) LabelHint() *ui.Label {
return s.labelHint
}
// Move things into position for the main menu. This function arranges
// the Title, Subtitle, Buttons, etc. into screen relative positions every
// tick. This function sets their 'default' values, but if the window is
// not tall enough and needs the landscape orientation, positionMenuLandscape()
// will override these defaults.
func (s *MainScene) positionMenuPortrait(d *Doodle) {
// App title label.
s.labelTitle.MoveTo(render.Point{
X: (d.width / 2) - (s.labelTitle.Size().W / 2),
Y: 120,
})
// App subtitle label (byline).
s.labelSubtitle.MoveTo(render.Point{
X: (d.width / 2) - (s.labelSubtitle.Size().W / 2),
Y: s.labelTitle.Point().Y + s.labelTitle.Size().H + 8,
})
// Version label
s.labelVersion.MoveTo(render.Point{
X: (d.width) - (s.labelVersion.Size().W) - 20,
Y: 20,
})
// Hint label.
s.labelHint.MoveTo(render.Point{
X: (d.width / 2) - (s.labelHint.Size().W / 2),
Y: d.height - s.labelHint.Size().H - 32,
})
// Update button.
s.updateButton.MoveTo(render.Point{
X: 24,
Y: d.height - s.updateButton.Size().H - 24,
})
// Button frame.
s.frame.MoveTo(render.Point{
X: (d.width / 2) - (s.frame.Size().W / 2),
Y: 260,
})
}
func (s *MainScene) positionMenuLandscape(d *Doodle) {
s.positionMenuPortrait(d)
var (
col1 = render.Rect{
X: 0,
Y: 0,
W: d.width / 2,
H: d.height,
}
col2 = render.Rect{
X: d.width,
Y: 0,
W: d.width - col1.W,
H: d.height,
}
)
// Title and subtitle move to the left.
s.labelTitle.MoveTo(render.Point{
X: (col1.W / 2) - (s.labelTitle.Size().W / 2),
Y: s.labelTitle.Point().Y,
})
s.labelSubtitle.MoveTo(render.Point{
X: (col1.W / 2) - (s.labelSubtitle.Size().W / 2),
Y: s.labelTitle.Point().Y + s.labelTitle.Size().H + 8,
})
// Button frame to the right.
s.frame.MoveTo(render.Point{
X: (col2.X+col2.W)/2 - (s.frame.Size().W / 2),
Y: (d.height / 2) - (s.frame.Size().H / 2),
})
}
// LoopLazyScroll gently scrolls the title screen demo level, called each Loop.
func (s *MainScene) LoopLazyScroll() {
if s.PauseLazyScroll {
return
}
// The v1 basic sauce algorithm:
// 1. We scroll diagonally downwards and rightwards.
// 2. When we scroll downwards far enough, we change direction.
// Make a zigzag pattern.
// 3. When we reach the right bound of the level
// OR some max number of px into an unbounded level:
// enter a simple ball bouncing mode like a screensaver.
var (
zigzagMaxHeight = 512
maxScrollX = zigzagMaxHeight * 2
lastScrollValue = s.lazyScrollLastValue
currentScroll = s.canvas.Scroll
)
// So we have two states:
// - Zigzag state (default)
// - Bounce state (when we hit a wall)
if !s.lazyScrollBounce {
// Zigzag state.
s.lazyScrollTrajectory = render.Point{
X: -1, // down and right
Y: -1,
}
// When we've gone far enough X, it's also far enough Y.
if currentScroll.X < -zigzagMaxHeight {
s.lazyScrollTrajectory.Y = 1 // go back up
}
// Have we gotten stuck in a corner? (ending the zigzag phase, for bounded levels)
if currentScroll.X < 0 && (currentScroll == lastScrollValue) || currentScroll.X < -maxScrollX {
log.Debug("LoopLazyScroll: Ending zigzag phase, enter bounce phase")
s.lazyScrollBounce = true
s.lazyScrollTrajectory = render.Point{
X: -1,
Y: -1,
}
}
} else {
var (
// Bounded and bordered levels will naturally hit
// an edge and stop scrolling
bounceY = currentScroll.Y == lastScrollValue.Y
bounceX = currentScroll.X == lastScrollValue.X
worldsize = s.canvas.Chunker().WorldSize()
viewport = s.canvas.Viewport()
)
// In case of unbounded levels, set limits ourself.
if !bounceX {
if viewport.X < worldsize.X || viewport.X > worldsize.W {
bounceX = true
// Set the trajectory the right direction immediately.
if viewport.X < worldsize.X {
s.lazyScrollTrajectory.X = 1
} else {
s.lazyScrollTrajectory.X = -1
}
}
}
if !bounceY {
if viewport.Y < worldsize.Y || viewport.Y > worldsize.H {
bounceY = true
// Set the trajectory the right direction immediately.
if viewport.Y < worldsize.Y {
s.lazyScrollTrajectory.Y = 1
} else {
s.lazyScrollTrajectory.Y = -1
}
}
}
// Lazy bounce algorithm.
if bounceY {
log.Debug("LoopLazyScroll: Hit a floor/ceiling")
s.lazyScrollTrajectory.Y = -s.lazyScrollTrajectory.Y
}
if bounceX {
log.Debug("LoopLazyScroll: Hit the side of the map!")
s.lazyScrollTrajectory.X = -s.lazyScrollTrajectory.X
}
}
// Check the scroll.
s.lazyScrollLastValue = currentScroll
s.canvas.ScrollBy(s.lazyScrollTrajectory)
}
// Draw the pixels on this frame.
func (s *MainScene) Draw(d *Doodle) error {
// Clear the canvas and fill it with white.
d.Engine.Clear(render.White)
s.canvas.Present(d.Engine, render.Origin)
// Draw a sheen over the level for clarity.
d.Engine.DrawBox(render.RGBA(255, 255, 254, 96), render.Rect{
X: 0,
Y: 0,
W: d.width,
H: d.height,
})
// Draw out bounding boxes.
if DebugCollision {
for _, actor := range s.canvas.Actors() {
d.DrawCollisionBox(s.canvas, actor)
}
}
// Arrange the main widgets by Portrait or Landscape mode.
if s.landscapeMode {
s.positionMenuLandscape(d)
} else {
s.positionMenuPortrait(d)
}
// App title label.
s.labelTitle.Present(d.Engine, s.labelTitle.Point())
// App subtitle label (byline).
s.labelSubtitle.Present(d.Engine, s.labelSubtitle.Point())
// Version label
s.labelVersion.Present(d.Engine, s.labelVersion.Point())
// Hint label.
s.labelHint.Present(d.Engine, s.labelHint.Point())
// Update button.
s.updateButton.Present(d.Engine, s.updateButton.Point())
s.frame.Compute(d.Engine)
s.frame.Present(d.Engine, s.frame.Point())
// Present supervised windows.
s.Supervisor.Present(d.Engine)
return nil
}
// Destroy the scene.
func (s *MainScene) Destroy() error {
log.Debug("MainScene.Destroy(): clean up the demo level canvas")
s.canvas.Destroy()
return nil
}