WIP LevelPack UI + Landscape Mode Title Screen

The title screen is now responsive to landscape mode. If the window is
not tall enough to show all the menu buttons (~600px) it will switch to
a horizontal layout with the title on the left and buttons on the right.

WIP "Story Mode" button that brings up a Level Packs selection window.
This commit is contained in:
Noah 2021-12-23 21:11:45 -08:00
parent a75b7208ca
commit 678326540b
8 changed files with 470 additions and 52 deletions

View File

@ -12,6 +12,10 @@ 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

View File

@ -13,6 +13,7 @@ const (
// File extensions
const (
LevelExt = ".level"
DoodadExt = ".doodad"
LevelExt = ".level"
DoodadExt = ".doodad"
LevelPackExt = ".levelpack"
)

View File

@ -26,9 +26,10 @@ const (
// Paths to system-level assets bundled with the application.
var (
SystemDoodadsPath = filepath.Join("assets", "doodads")
SystemLevelsPath = filepath.Join("assets", "levels")
SystemCampaignsPath = filepath.Join("assets", "campaigns")
SystemDoodadsPath = filepath.Join("assets", "doodads")
SystemLevelsPath = filepath.Join("assets", "levels")
SystemCampaignsPath = filepath.Join("assets", "campaigns")
SystemLevelPacksPath = filepath.Join("assets", "levelpacks")
)
// MakeHeader creates the binary file header.

View File

@ -7,8 +7,14 @@ import (
"errors"
"io/ioutil"
"os"
"runtime"
"strings"
"time"
"git.kirsle.net/apps/doodle/assets"
"git.kirsle.net/apps/doodle/pkg/enum"
"git.kirsle.net/apps/doodle/pkg/filesystem"
"git.kirsle.net/apps/doodle/pkg/userdir"
)
// LevelPack describes the contents of a levelpack file.
@ -63,6 +69,42 @@ func LoadFile(filename string) (LevelPack, error) {
return lp, nil
}
// ListFiles lists all the discoverable levelpack files, starting from
// the game's built-ins all the way to user levelpacks.
func ListFiles() ([]string, error) {
var names []string
// List levelpacks embedded into the binary.
if files, err := assets.AssetDir("assets/levelpacks"); err == nil {
names = append(names, files...)
}
// WASM stops here, no filesystem access.
if runtime.GOOS == "js" {
return names, nil
}
// Read system-level levelpacks.
files, _ := ioutil.ReadDir(filesystem.SystemLevelPacksPath)
for _, file := range files {
name := file.Name()
if strings.HasSuffix(name, enum.LevelPackExt) {
names = append(names, name)
}
}
// Append user levelpacks.
files, _ = ioutil.ReadDir(userdir.LevelPackDirectory)
for _, file := range files {
name := file.Name()
if strings.HasSuffix(name, enum.LevelPackExt) {
names = append(names, name)
}
}
return names, nil
}
// WriteFile saves the metadata to a .json file on disk.
func (l LevelPack) WriteFile(filename string) error {
out, err := json.Marshal(l)

View File

@ -38,6 +38,7 @@ type MainScene struct {
btnRegister *ui.Button
winRegister *ui.Window
winSettings *ui.Window
winLevelPacks *ui.Window
// Update check variables.
updateButton *ui.Button
@ -47,6 +48,12 @@ type MainScene struct {
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
}
// Name of the scene.
@ -161,10 +168,22 @@ func (s *MainScene) Setup(d *Doodle) error {
Func func()
Style *style.Button
}{
// {
// Name: "Story Mode",
// Func: d.GotoStoryMenu,
// },
{
Name: "Story Mode",
Func: func() {
if s.winLevelPacks == nil {
s.winLevelPacks = windows.NewLevelPackWindow(windows.LevelPack{
Supervisor: s.Supervisor,
Engine: d.Engine,
})
}
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()
},
},
{
Name: "Play a Level",
Func: d.GotoPlayMenu,
@ -229,6 +248,10 @@ func (s *MainScene) Setup(d *Doodle) error {
}
}()
// 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
}
@ -310,16 +333,121 @@ func (s *MainScene) Loop(d *Doodle, ev *event.State) error {
w, h := d.Engine.WindowSize()
d.width = w
d.height = h
log.Info("Resized to %dx%d", d.width, d.height)
s.canvas.Resize(render.Rect{
W: d.width,
H: d.height,
})
s.Resized(w, h)
}
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,
})
}
// 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,
})
// Register button.
s.btnRegister.MoveTo(render.Point{
X: d.width - s.btnRegister.Size().W - 24,
Y: d.height - s.btnRegister.Size().H - 24,
})
}
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),
})
// Register button to the top left.
// TODO: not ideal, move into main button list?
s.btnRegister.MoveTo(render.Point{
X: 20,
Y: 20,
})
}
// LoopLazyScroll gently scrolls the title screen demo level, called each Loop.
func (s *MainScene) LoopLazyScroll() {
// The v1 basic sauce algorithm:
@ -399,53 +527,32 @@ func (s *MainScene) Draw(d *Doodle) error {
}
}
// Arrange the main widgets by Portrait or Landscape mode.
if s.landscapeMode {
s.positionMenuLandscape(d)
} else {
s.positionMenuPortrait(d)
}
// App title label.
s.labelTitle.MoveTo(render.Point{
X: (d.width / 2) - (s.labelTitle.Size().W / 2),
Y: 120,
})
s.labelTitle.Present(d.Engine, s.labelTitle.Point())
// 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,
})
s.labelSubtitle.Present(d.Engine, s.labelSubtitle.Point())
// Version label
s.labelVersion.MoveTo(render.Point{
X: (d.width) - (s.labelVersion.Size().W) - 20,
Y: 20,
})
s.labelVersion.Present(d.Engine, s.labelVersion.Point())
// Hint label.
s.labelHint.MoveTo(render.Point{
X: (d.width / 2) - (s.labelHint.Size().W / 2),
Y: d.height - s.labelHint.Size().H - 32,
})
s.labelHint.Present(d.Engine, s.labelHint.Point())
// Update button.
s.updateButton.MoveTo(render.Point{
X: 24,
Y: d.height - s.updateButton.Size().H - 24,
})
s.updateButton.Present(d.Engine, s.updateButton.Point())
s.frame.Compute(d.Engine)
s.frame.MoveTo(render.Point{
X: (d.width / 2) - (s.frame.Size().W / 2),
Y: 260,
})
s.frame.Present(d.Engine, s.frame.Point())
// Register button.
s.btnRegister.MoveTo(render.Point{
X: d.width - s.btnRegister.Size().W - 24,
Y: d.height - s.btnRegister.Size().H - 24,
})
s.btnRegister.Present(d.Engine, s.btnRegister.Point())
// Present supervised windows.

View File

@ -6,6 +6,7 @@ import (
"git.kirsle.net/apps/doodle/pkg/level"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/uix"
"git.kirsle.net/apps/doodle/pkg/windows"
"git.kirsle.net/go/render"
"git.kirsle.net/go/render/event"
"git.kirsle.net/go/ui"
@ -22,8 +23,8 @@ type StoryScene struct {
// UI widgets.
supervisor *ui.Supervisor
campaignFrame *ui.Frame // Select a Campaign screen
levelSelectFrame *ui.Frame // Select a level in the campaign screen
campaignFrame *ui.Frame // Select a Campaign screen
levelSelectFrame *ui.Window // Select a level in the campaign screen
// Pointer to the currently active frame.
activeFrame *ui.Frame
@ -59,7 +60,13 @@ func (s *StoryScene) Setup(d *Doodle) error {
// Set up the sub-screens of this scene.
s.campaignFrame = s.setupCampaignFrame()
s.levelSelectFrame = s.setupLevelSelectFrame()
s.levelSelectFrame = windows.NewLevelPackWindow(windows.LevelPack{
Supervisor: s.supervisor,
Engine: d.Engine,
OnPlayLevel: func(levelpack, filename string) {},
})
s.levelSelectFrame.Show()
s.activeFrame = s.campaignFrame
@ -100,13 +107,6 @@ func (s *StoryScene) setupCampaignFrame() *ui.Frame {
return frame
}
// setupLevelSelectFrame sets up the Level Select screen.
func (s *StoryScene) setupLevelSelectFrame() *ui.Frame {
var frame = ui.NewFrame("List Frame")
return frame
}
// Loop the story scene.
func (s *StoryScene) Loop(d *Doodle, ev *event.State) error {
s.supervisor.Loop(ev)
@ -135,6 +135,8 @@ func (s *StoryScene) Draw(d *Doodle) error {
// Draw the active screen.
s.activeFrame.Present(d.Engine, render.Origin)
s.supervisor.Present(d.Engine)
return nil
}

View File

@ -17,6 +17,7 @@ var (
ProfileDirectory string
LevelDirectory string
LevelPackDirectory string
DoodadDirectory string
CampaignDirectory string
ScreenshotDirectory string
@ -35,6 +36,7 @@ func init() {
// Profile directory contains the user's levels and doodads.
ProfileDirectory = configdir.LocalConfig(ConfigDirectoryName)
LevelDirectory = configdir.LocalConfig(ConfigDirectoryName, "levels")
LevelPackDirectory = configdir.LocalConfig(ConfigDirectoryName, "levelpacks")
DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads")
CampaignDirectory = configdir.LocalConfig(ConfigDirectoryName, "campaigns")
ScreenshotDirectory = configdir.LocalConfig(ConfigDirectoryName, "screenshots")
@ -47,6 +49,7 @@ func init() {
// WASM: do not make paths in wasm.
if runtime.GOOS != "js" {
configdir.MakePath(LevelDirectory)
configdir.MakePath(LevelPackDirectory)
configdir.MakePath(DoodadDirectory)
configdir.MakePath(CampaignDirectory)
configdir.MakePath(FontDirectory)

View File

@ -0,0 +1,258 @@
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/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(levelpack, filename string)
// Internal variables
window *ui.Window
gotoIndex func() // return to index screen
}
// 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 = 300
)
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,
})
// We'll divide this window into "Screens", where the default
// screen shows the available level packs and then each level
// pack gets its own screen showing its levels.
var indexScreen *ui.Frame
config.gotoIndex = func() {
indexScreen.Show()
}
indexScreen = config.makeIndexScreen(width, height, func(screen *ui.Frame) {
// Callback for user choosing a level pack.
// Hide the index screen and show the screen for this pack.
indexScreen.Hide()
screen.Show()
})
window.Pack(indexScreen, ui.Pack{
Side: ui.N,
Fill: true,
Expand: true,
})
window.Supervise(config.Supervisor)
window.Hide()
return window
}
// Index screen for the LevelPack window.
func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Frame)) *ui.Frame {
var (
buttonHeight = 60 // height of each LevelPack button
buttonWidth = width - 40
// pagination values
page = 1
pages int
perPage = 3
maxPageButtons = 10
)
frame := ui.NewFrame("Index Screen")
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,
})
// Get the available .levelpack files.
lpFiles, err := levelpack.ListFiles()
if err != nil {
log.Error("Couldn't list levelpack files: %s", err)
}
pages = int(
math.Ceil(
float64(len(lpFiles)) / float64(perPage),
),
)
var buttons []*ui.Button
for i, filename := range lpFiles {
lp, err := levelpack.LoadFile(filename)
if err != nil {
log.Error("Couldn't read %s: %s", filename, err)
continue
}
_ = lp
// 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("[%d levels]", len(lp.Levels)),
Font: balance.MenuFont,
})
btnFrame.Pack(numLevels, ui.Pack{
Side: ui.N,
})
// Generate the detail screen (Frame) for this level pack.
// Should the user click our button, this screen is shown.
screen := config.makeDetailScreen(width, height, lp)
screen.Hide()
config.window.Pack(screen, ui.Pack{
Side: ui.N,
Fill: true,
Expand: true,
})
button := ui.NewButton(filename, btnFrame)
button.Handle(ui.Click, func(ed ui.EventData) error {
onChoose(screen)
return nil
})
frame.Pack(button, ui.Pack{
Side: ui.N,
PadY: 2,
})
config.Supervisor.Add(button)
if i > perPage {
button.Hide()
}
buttons = append(buttons, button)
}
pager := ui.NewPager(ui.Pager{
Name: "LevelPack Pager",
Page: page,
Pages: pages,
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
}
// Detail screen for a given levelpack.
func (config LevelPack) makeDetailScreen(width, height int, lp levelpack.LevelPack) *ui.Frame {
frame := ui.NewFrame("Detail Screen")
label := ui.NewLabel(ui.Label{
Text: "HELLO " + lp.Title,
Font: balance.LabelFont,
})
frame.Pack(label, ui.Pack{
Side: ui.N,
PadX: 8,
PadY: 8,
})
backButton := ui.NewButton("Back", ui.NewLabel(ui.Label{
Text: "< Back to Level Packs",
Font: ui.MenuFont,
}))
backButton.Handle(ui.Click, func(ed ui.EventData) error {
frame.Hide()
config.gotoIndex()
return nil
})
config.Supervisor.Add(backButton)
frame.Pack(backButton, ui.Pack{
Side: ui.N,
PadY: 2,
})
return frame
}