Level Thumbnails on Story Mode Select

* Rework the Story Mode UI to display level thumbnails.
  * Responsive UI: defaults to wide screen mode and shows 3 levels horizontally
    but on narrow/mobile display, shows 2 levels per page in portrait.
  * Add "Tiny" screenshot size (224x126) to fit the Story Mode UI.
  * Make the pager buttons bigger and more touchable.
* Maximize the game window on startup unless the -w option with a specific
  window resolution is provided.
This commit is contained in:
Noah 2023-12-09 14:59:31 -08:00
parent 9cce93f431
commit 1a9706c09f
24 changed files with 245 additions and 94 deletions

View File

@ -3,7 +3,6 @@ package main
import (
"errors"
"fmt"
"math/rand"
"os"
"regexp"
"runtime"
@ -47,9 +46,6 @@ func init() {
// Use all the CPU cores for collision detection and other load balanced
// goroutine work in the app.
runtime.GOMAXPROCS(runtime.NumCPU())
// Seed the random number generator.
rand.Seed(time.Now().UnixNano())
}
func main() {
@ -154,10 +150,12 @@ func main() {
}
// Setting a custom resolution?
var maximize = true
if c.String("window") != "" {
if err := setResolution(c.String("window")); err != nil {
panic(err)
}
maximize = false
}
// Enable feature flags?
@ -200,6 +198,12 @@ func main() {
game := doodle.New(c.Bool("debug"), engine)
game.SetupEngine()
// Start with maximized window unless -w was given.
if maximize {
log.Info("Maximize window")
engine.Maximize()
}
// Reload usercfg - if their settings.json doesn't exist, we try and pick a
// default "hide touch hints" based on touch device presence - which is only
// known after SetupEngine.

View File

@ -1,3 +1,4 @@
//go:build shareware
// +build shareware
package balance

View File

@ -1,3 +1,4 @@
//go:build !shareware
// +build !shareware
package balance

View File

@ -21,10 +21,6 @@ var (
Width = 1024
Height = 768
// Title screen height needed for the main menu. Phones in landscape
// mode will switch to the horizontal layout if less than this height.
TitleScreenResponsiveHeight = 600
// Speed to scroll a canvas with arrow keys in Edit Mode.
CanvasScrollSpeed = 8
FollowActorMaxScrollSpeed = 64
@ -158,9 +154,11 @@ var (
LevelScreenshotLargeFilename = "large.png"
LevelScreenshotMediumFilename = "medium.png"
LevelScreenshotSmallFilename = "small.png"
LevelScreenshotTinyFilename = "tiny.png"
LevelScreenshotLargeSize = render.NewRect(1280, 720)
LevelScreenshotMediumSize = render.NewRect(640, 360)
LevelScreenshotSmallSize = render.NewRect(320, 180)
LevelScreenshotSmallSize = render.NewRect(320, 180) // Level Properties thumbnail size
LevelScreenshotTinySize = render.NewRect(224, 126) // Story Mode thumbnail size
)
// Edit Mode Values

36
pkg/balance/responsive.go Normal file
View File

@ -0,0 +1,36 @@
package balance
/*
Responsive breakpoints and dimensions for Sketchy Maze.
Ideas for breakpoints (copying web CSS frameworks):
- Mobile up to 768px
- Tablet from 769px
- Desktop from 1024px
- Widescreen from 1216px
- FullHD from 1408px
*/
const (
// Title screen height needed for the main menu. Phones in landscape
// mode will switch to the horizontal layout if less than this height.
TitleScreenResponsiveHeight = 600
BreakpointMobile = 0 // 0-768
BreakpointTablet = 769 // from 769
BreakpointDesktop = 1024 // from 1024
BreakpointWidescreen = 1216
BreakpointFullHD = 1408
)
// IsShortWide is a custom responsive breakpoint to mimic the mobile app in landscape mode like on a Pinephone.
//
// Parameters are the width and height of the application window (usually the screen if maximized).
//
// It is used on the MainScene to decide whether the main menu is drawn tall or wide.
func IsShortWide(width, height int) bool {
return height < TitleScreenResponsiveHeight
}
func IsBreakpointTablet(width, height int) bool {
return width >= BreakpointTablet
}

View File

@ -110,6 +110,7 @@ var (
MenuFont = render.Text{
Size: 12,
PadX: 4,
PadY: 2,
}
MenuFontBold = render.Text{
FontFilename: SansBoldFont,
@ -124,6 +125,14 @@ var (
PadY: 4,
}
// Pager styles.
PagerLargeFont = render.Text{
FontFilename: SansBoldFont,
Size: 14,
PadX: 6,
PadY: 4,
}
// Modal backdrop color.
ModalBackdrop = render.RGBA(1, 1, 1, 42)

View File

@ -21,9 +21,9 @@ EditFile opens a drawing file (Level or Doodad) in the EditorScene.
The filename can be one of the following:
- A simple filename with no path separators in it and/or no file extension.
- An absolute path beginning with "/"
- A relative path beginning with "./"
- A simple filename with no path separators in it and/or no file extension.
- An absolute path beginning with "/"
- A relative path beginning with "./"
If the filename has an extension (`.level` or `.doodad`), that will disambiguate
how to find the file and which mode to start the EditorMode in. Otherwise, the

View File

@ -7,12 +7,12 @@ Stroke holds temporary pixel data with a shape and color.
It is used for myriad purposes:
- As a staging area for drawing new pixels to the drawing without committing
them until completed.
- As a unit of work for the Undo/Redo History when editing a drawing.
- As imaginary visual lines superimposed on top of a drawing, for example to
visualize the link between two doodads or to draw collision hitboxes and other
debug lines to the drawing.
- As a staging area for drawing new pixels to the drawing without committing
them until completed.
- As a unit of work for the Undo/Redo History when editing a drawing.
- As imaginary visual lines superimposed on top of a drawing, for example to
visualize the link between two doodads or to draw collision hitboxes and other
debug lines to the drawing.
*/
type Stroke struct {
ID int // Unique ID per each stroke

View File

@ -133,7 +133,7 @@ func SaveCroppedScreenshot(level *level.Level, viewport render.Rect) (string, er
// UpdateLevelScreenshots will generate and embed the screenshot PNGs into the level data.
func UpdateLevelScreenshots(lvl *level.Level, scroll render.Point) error {
// Take screenshots.
large, medium, small, err := CreateLevelScreenshots(lvl, scroll)
large, medium, small, tiny, err := CreateLevelScreenshots(lvl, scroll)
if err != nil {
return err
}
@ -143,6 +143,7 @@ func UpdateLevelScreenshots(lvl *level.Level, scroll render.Point) error {
balance.LevelScreenshotLargeFilename: large,
balance.LevelScreenshotMediumFilename: medium,
balance.LevelScreenshotSmallFilename: small,
balance.LevelScreenshotTinyFilename: tiny,
} {
var fh = bytes.NewBuffer([]byte{})
if err := png.Encode(fh, img); err != nil {
@ -165,7 +166,7 @@ func UpdateLevelScreenshots(lvl *level.Level, scroll render.Point) error {
// will be embedded within the level data itself.
//
// Returns the large, medium and small images.
func CreateLevelScreenshots(lvl *level.Level, scroll render.Point) (large, medium, small image.Image, err error) {
func CreateLevelScreenshots(lvl *level.Level, scroll render.Point) (large, medium, small, tiny image.Image, err error) {
// Viewport to screenshot.
viewport := render.Rect{
X: scroll.X,
@ -183,7 +184,8 @@ func CreateLevelScreenshots(lvl *level.Level, scroll render.Point) (large, mediu
// Scale the medium and small versions.
medium = Scale(large, image.Rect(0, 0, balance.LevelScreenshotMediumSize.W, balance.LevelScreenshotMediumSize.H), draw.ApproxBiLinear)
small = Scale(large, image.Rect(0, 0, balance.LevelScreenshotSmallSize.W, balance.LevelScreenshotSmallSize.H), draw.ApproxBiLinear)
return large, medium, small, nil
tiny = Scale(large, image.Rect(0, 0, balance.LevelScreenshotTinySize.W, balance.LevelScreenshotTinySize.H), draw.ApproxBiLinear)
return large, medium, small, tiny, nil
}
// Scale down an image. Example:

View File

@ -9,10 +9,10 @@ by its index number.
This function calls the following:
* Chunker.Inflate(Palette) to update references to the level's pixels to point
to the Swatch entry.
* Actors.Inflate()
* Palette.Inflate() to load private instance values for the palette subsystem.
- Chunker.Inflate(Palette) to update references to the level's pixels to point
to the Swatch entry.
- Actors.Inflate()
- Palette.Inflate() to load private instance values for the palette subsystem.
*/
func (l *Level) Inflate() {
// Inflate the chunk metadata to map the pixels to their palette indexes.

View File

@ -497,12 +497,11 @@ 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
isLandscape := balance.IsShortWide(width, height)
if isLandscape != s.landscapeMode {
log.Info("Toggled LandscapeMode to: %+v", isLandscape)
}
s.landscapeMode = isLandscape
s.canvas.Resize(render.Rect{
W: width,

View File

@ -1,3 +1,4 @@
//go:build !js
// +build !js
package native
@ -14,8 +15,8 @@ import (
// OpenURL opens a web browser to the given URL.
//
// On Linux this will look for xdg-open or try a few common browser names.
// On Windows this uses the ``start`` command.
// On MacOS this uses the ``open`` command.
// On Windows this uses the “start“ command.
// On MacOS this uses the “open“ command.
func OpenURL(url string) {
if runtime.GOOS == "windows" {
go windowsOpenURL(url)

View File

@ -1,3 +1,4 @@
//go:build js && wasm
// +build js,wasm
package native

View File

@ -109,3 +109,10 @@ func TextToImage(e render.Engine, text render.Text) (image.Image, error) {
return img, nil
}
// Set the window to maximized.
func MaximizeWindow(e render.Engine) {
if sdl, ok := e.(*sdl.Renderer); ok {
sdl.Maximize()
}
}

View File

@ -25,3 +25,5 @@ func CopyToClipboard(text string) error {
func CountTextures(e render.Engine) string {
return "n/a"
}
func MaximizeWindow(e render.Engine) {}

View File

@ -1,3 +1,4 @@
//go:build js && wasm
// +build js,wasm
package native
@ -17,4 +18,3 @@ func OpenFile(title string, filter string) (string, error) {
})
return "", nil
}

View File

@ -1,3 +1,4 @@
//go:build !js
// +build !js
package native

View File

@ -7,8 +7,10 @@ RegisterEventHooks attaches the supervisor level event hooks into a JS VM.
Names registered:
- EndLevel(): for a doodad to exit the level. Panics if the OnLevelExit
handler isn't defined.
- EndLevel(): for a doodad to exit the level. Panics if the OnLevelExit
handler isn't defined.
- FailLevel(): for a doodad to cause a level failure.
- SetCheckpoint(): update the player's respawn location.
*/
func RegisterEventHooks(s *Supervisor, vm *VM) {
vm.Set("EndLevel", func() {

View File

@ -1,4 +1,5 @@
//+build js,wasm
//go:build js && wasm
// +build js,wasm
package sound

View File

@ -1,9 +1,9 @@
package wallpaper
import (
"os"
"io/ioutil"
"encoding/base64"
"io/ioutil"
"os"
)
/*

View File

@ -1,3 +1,4 @@
//go:build !js
// +build !js
package wasm

View File

@ -1,3 +1,4 @@
//go:build js && wasm
// +build js,wasm
package wasm

View File

@ -24,11 +24,16 @@ type LevelPack struct {
OnCloseWindow func()
// Internal variables
isLandscape bool // wide window rather than tall
window *ui.Window
tabFrame *ui.TabFrame
savegame *savegame.SaveGame
goldSprite *ui.Image
silverSprite *ui.Image
// Button frames for the footer: one with Back+Close, other with Close only.
footerWithBackButton *ui.Frame
footerWithCloseButton *ui.Frame
}
// NewLevelPackWindow initializes the window.
@ -37,11 +42,18 @@ func NewLevelPackWindow(config LevelPack) *ui.Window {
var (
title = "Select a Level"
// size of the popup window
// size of the popup window (vertical)
width = 320
height = 360
height = 540
)
// Are we horizontal?
if balance.IsBreakpointTablet(config.Engine.WindowSize()) {
width = 720
height = 360
config.isLandscape = true
}
// Get the available .levelpack files.
lpFiles, packmap, err := levelpack.LoadAllAvailable()
if err != nil {
@ -70,6 +82,15 @@ func NewLevelPackWindow(config LevelPack) *ui.Window {
Height: height,
Background: render.Grey,
})
window.Handle(ui.CloseWindow, func(ed ui.EventData) error {
if config.OnCloseWindow != nil {
// fn := config.OnCloseWindow
// config.OnCloseWindow = nil
// fn()
config.OnCloseWindow()
}
return nil
})
config.window = window
frame := ui.NewFrame("Window Body Frame")
@ -99,6 +120,8 @@ func NewLevelPackWindow(config LevelPack) *ui.Window {
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.
config.footerWithBackButton.Show()
config.footerWithCloseButton.Hide()
tabFrame.SetTab(screen)
})
for _, filename := range lpFiles {
@ -109,20 +132,65 @@ func NewLevelPackWindow(config LevelPack) *ui.Window {
config.makeDetailScreen(tab, width, height, packmap[filename])
}
// Close button.
// Button toolbar at the bottom (Back, Close)
config.footerWithBackButton = ui.NewFrame("Button Bar w/ Back Button")
config.footerWithCloseButton = ui.NewFrame("Button Bar w/ Close Button Only")
window.Place(config.footerWithBackButton, ui.Place{
Bottom: 15,
Center: true,
})
window.Place(config.footerWithCloseButton, ui.Place{
Bottom: 15,
Center: true,
})
// Back button hidden by default.
config.footerWithBackButton.Hide()
// Back button (conditionally visible)
backButton := ui.NewButton("Back", ui.NewLabel(ui.Label{
Text: "« Back",
Font: balance.MenuFont,
}))
backButton.SetStyle(&balance.ButtonBabyBlue)
backButton.Handle(ui.Click, func(ed ui.EventData) error {
tabFrame.SetTab("LevelPacks")
config.footerWithBackButton.Hide()
config.footerWithCloseButton.Show()
return nil
})
config.Supervisor.Add(backButton)
config.footerWithBackButton.Pack(backButton, ui.Pack{
Side: ui.W,
PadX: 4,
})
// Close button (on both versions of the footer frame).
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
// Create two copies of the button, so we can parent one to each footer frame.
makeCloseButton := func() *ui.Button {
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)
return closeBtn
}
var (
button1 = makeCloseButton()
button2 = makeCloseButton()
)
// Add it to both frames.
config.footerWithBackButton.Pack(button1, ui.Pack{
Side: ui.W,
})
config.Supervisor.Add(closeBtn)
window.Place(closeBtn, ui.Place{
Bottom: 15,
Center: true,
config.footerWithCloseButton.Pack(button2, ui.Pack{
Side: ui.W,
})
}
@ -235,7 +303,7 @@ func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int,
Pages: pages,
PerPage: perPage,
MaxPageButtons: maxPageButtons,
Font: balance.MenuFont,
Font: balance.PagerLargeFont,
OnChange: func(newPage, perPage int) {
page = newPage
log.Info("Page: %d, %d", page, perPage)
@ -271,18 +339,32 @@ func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int,
// 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
perPage = 2 // 2 for tall mobile, 3 for landscape
pages = int(
math.Ceil(
float64(len(lp.Levels)) / float64(perPage),
),
)
maxPageButtons = 10
buttonHeight = 172
buttonWidth = 230
thumbnailName = balance.LevelScreenshotTinyFilename
thumbnailPadY = 46
)
if config.isLandscape {
perPage = 3
pages = int(
math.Ceil(
float64(len(lp.Levels)) / float64(perPage),
),
)
buttonHeight = 172
thumbnailName = balance.LevelScreenshotTinyFilename
thumbnailPadY = 46
buttonWidth = (width / perPage) - 16 // pixel-pushing
}
// Load the padlock icon for locked levels.
// If not loadable, won't be used in UI.
@ -294,42 +376,13 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
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,
Side: ui.N,
PadY: 2,
})
@ -359,12 +412,36 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
})
}
// Arranging the buttons into groups of 3, vertical or horizontal.
var packDir = ui.Pack{
Side: ui.N,
PadY: 2,
}
if config.isLandscape {
packDir = ui.Pack{
Side: ui.W,
PadX: 2,
}
}
buttonRow := ui.NewFrame("Level Buttons")
frame.Pack(buttonRow, ui.Pack{
Side: ui.N,
PadY: 4,
})
// 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, level.UUID)
// Load the level zip for its thumbnail image.
lvl, err := lp.GetLevel(level.Filename)
if err != nil {
log.Error("Couldn't GetLevel(%s) from LevelPack %s: %s", level.Filename, lp.Filename, err)
lvl = nil
}
// Make a frame to hold a complex button layout.
btnFrame := ui.NewFrame("Frame")
btnFrame.Resize(render.Rect{
@ -464,6 +541,16 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
})
}
// Level screenshot.
if lvl != nil {
if img, err := lvl.GetScreenshotImageAsUIImage(thumbnailName); err == nil {
btnFrame.Pack(img, ui.Pack{
Side: ui.N,
PadY: thumbnailPadY, // TODO: otherwise it overlaps the other labels :(
})
}
}
btn := ui.NewButton(level.Filename, btnFrame)
btn.Handle(ui.Click, func(ed ui.EventData) error {
// Is this level locked?
@ -484,10 +571,7 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
return nil
})
frame.Pack(btn, ui.Pack{
Side: ui.N,
PadY: 2,
})
buttonRow.Pack(btn, packDir)
config.Supervisor.Add(btn)
if i > perPage-1 {
@ -502,7 +586,7 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
Pages: pages,
PerPage: perPage,
MaxPageButtons: maxPageButtons,
Font: balance.MenuFont,
Font: balance.PagerLargeFont,
OnChange: func(newPage, perPage int) {
page = newPage
log.Info("Page: %d, %d", page, perPage)