diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index a1cb36b..3c32bb4 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -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 diff --git a/pkg/enum/enum.go b/pkg/enum/enum.go index ba71e35..d754d9b 100644 --- a/pkg/enum/enum.go +++ b/pkg/enum/enum.go @@ -13,6 +13,7 @@ const ( // File extensions const ( - LevelExt = ".level" - DoodadExt = ".doodad" + LevelExt = ".level" + DoodadExt = ".doodad" + LevelPackExt = ".levelpack" ) diff --git a/pkg/filesystem/filesystem.go b/pkg/filesystem/filesystem.go index b1d16aa..613ba81 100644 --- a/pkg/filesystem/filesystem.go +++ b/pkg/filesystem/filesystem.go @@ -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. diff --git a/pkg/levelpack/levelpack.go b/pkg/levelpack/levelpack.go index 346bb25..06a2648 100644 --- a/pkg/levelpack/levelpack.go +++ b/pkg/levelpack/levelpack.go @@ -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) diff --git a/pkg/main_scene.go b/pkg/main_scene.go index c1a351e..a8b6f67 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -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. diff --git a/pkg/story_scene.go b/pkg/story_scene.go index 3fbede0..fad2191 100644 --- a/pkg/story_scene.go +++ b/pkg/story_scene.go @@ -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 } diff --git a/pkg/userdir/userdir.go b/pkg/userdir/userdir.go index a559906..684b6ea 100644 --- a/pkg/userdir/userdir.go +++ b/pkg/userdir/userdir.go @@ -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) diff --git a/pkg/windows/levelpack_open.go b/pkg/windows/levelpack_open.go new file mode 100644 index 0000000..2bf8f8f --- /dev/null +++ b/pkg/windows/levelpack_open.go @@ -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 +}