From 6d3ffcd98cd9f1450b7c21a796b44eef3c7742bc Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 26 Dec 2021 20:48:29 -0800 Subject: [PATCH] Finalize basic functionality for Level Packs * The "Story Mode" button on the MainScene opens the levelpacks window. * Levelpacks from all places are shown (built-in and user files), basic level picker works. * When playing a level out of a levelpack: the PlayScene gets the file data from the zipfile and plays it OK. * When a levelpack level is solved, the "Next Level" button appears on the success modal and hitting Return will advance to the next level in the pack. The final level doesn't show this button. * The user can edit levelpack levels! Clicking the "Edit" button on the Play Mode moves the loaded level over to the EditScene and the user could save it to disk or edit/playtest it perfectly OK! The link to the levelpack is lost upon opening in the editor, so the "Next Level" victory button doesn't appear. --- .gitignore | 1 + bootstrap.py | 2 + pkg/doodle.go | 13 ++ pkg/filesystem/filesystem.go | 30 ++++ pkg/levelpack/levelpack.go | 45 +++++- pkg/main_scene.go | 8 + pkg/play_scene.go | 61 ++++++- pkg/story_scene.go | 2 - pkg/userdir/userdir.go | 10 +- pkg/windows/levelpack_open.go | 295 +++++++++++++++++++++++++--------- 10 files changed, 383 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index d27b7f1..4b94b9b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ wasm/assets/ *.wasm *.doodad *.level +*.levelpack docker/ubuntu docker/debian docker/fedora diff --git a/bootstrap.py b/bootstrap.py index d6b747c..a2fe2ca 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -121,6 +121,8 @@ def copy_assets(): shell("cp -rv deps/vendor/fonts assets/fonts") if not os.path.isdir("assets/levels"): shell("cp -rv deps/masters/levels assets/levels") + if not os.path.isdir("assets/levelpacks"): + shell("cp -rv deps/masters/levelpacks/levelpacks assets/levelpacks") if not os.path.isdir("rtp"): shell("mkdir -p rtp && cp -rv deps/rtp/* rtp/") diff --git a/pkg/doodle.go b/pkg/doodle.go index eb3475d..4ae0c15 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -11,6 +11,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/branding" "git.kirsle.net/apps/doodle/pkg/enum" "git.kirsle.net/apps/doodle/pkg/keybind" + "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/modal/loadscreen" @@ -330,3 +331,15 @@ func (d *Doodle) PlayLevel(filename string) error { d.Goto(scene) return nil } + +// PlayFromLevelpack initializes the Play Scene from a level as part of +// a levelpack. +func (d *Doodle) PlayFromLevelpack(pack levelpack.LevelPack, which levelpack.Level) error { + log.Info("Loading level %s from levelpack %s", which.Filename, pack.Title) + scene := &PlayScene{ + Filename: which.Filename, + LevelPack: &pack, + } + d.Goto(scene) + return nil +} diff --git a/pkg/filesystem/filesystem.go b/pkg/filesystem/filesystem.go index 613ba81..cbf7d52 100644 --- a/pkg/filesystem/filesystem.go +++ b/pkg/filesystem/filesystem.go @@ -98,6 +98,8 @@ func FindFile(filename string) (string, error) { filetype = enum.LevelExt } else if strings.HasSuffix(filename, enum.DoodadExt) { filetype = enum.DoodadExt + } else if strings.HasSuffix(filename, enum.LevelPackExt) { + filetype = enum.LevelPackExt } // Search level directories. @@ -156,5 +158,33 @@ func FindFile(filename string) (string, error) { } } + // Search levelpack directories. + if filetype == enum.LevelPackExt || filetype == "" { + // system levelpacks path + candidate := filepath.Join(SystemLevelPacksPath, filename) + + // embedded in binary? + if _, err := assets.Asset(candidate); err == nil { + return candidate, nil + } + + // WASM: can't check the filesystem. Let the caller go ahead and try + // loading via ajax request. + if runtime.GOOS == "js" { + return filename, nil + } + + // external system levelpack? + if _, err := os.Stat(candidate); !os.IsNotExist(err) { + return candidate, nil + } + + // user levelpacks + candidate = userdir.LevelPackPath(filename) + if _, err := os.Stat(candidate); !os.IsNotExist(err) { + return candidate, nil + } + } + return filename, errors.New("file not found") } diff --git a/pkg/levelpack/levelpack.go b/pkg/levelpack/levelpack.go index 06a2648..30d723a 100644 --- a/pkg/levelpack/levelpack.go +++ b/pkg/levelpack/levelpack.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "os" "runtime" + "sort" "strings" "time" @@ -69,6 +70,34 @@ func LoadFile(filename string) (LevelPack, error) { return lp, nil } +// LoadAllAvailable loads every levelpack visible to the game. Returns +// the sorted list of filenames as from ListFiles, plus a deeply loaded +// hash map associating the filenames with their data. +func LoadAllAvailable() ([]string, map[string]LevelPack, error) { + filenames, err := ListFiles() + if err != nil { + return filenames, nil, err + } + + var dictionary = map[string]LevelPack{} + for _, filename := range filenames { + // Resolve the filename to a definite path on disk. + path, err := filesystem.FindFile(filename) + if err != nil { + return filenames, nil, err + } + + lp, err := LoadFile(path) + if err != nil { + return filenames, nil, err + } + + dictionary[filename] = lp + } + + return filenames, dictionary, 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) { @@ -102,7 +131,21 @@ func ListFiles() ([]string, error) { } } - return names, nil + // Deduplicate strings. Can happen e.g. because assets/ is baked + // in to bindata but files also exist there locally. + var ( + dedupe []string + seen = map[string]interface{}{} + ) + for _, value := range names { + if _, ok := seen[value]; !ok { + seen[value] = nil + dedupe = append(dedupe, value) + } + } + + sort.Strings(dedupe) + return dedupe, nil } // WriteFile saves the metadata to a .json file on disk. diff --git a/pkg/main_scene.go b/pkg/main_scene.go index a8b6f67..b6c6f0b 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -6,6 +6,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/branding" "git.kirsle.net/apps/doodle/pkg/level" + "git.kirsle.net/apps/doodle/pkg/levelpack" "git.kirsle.net/apps/doodle/pkg/license" "git.kirsle.net/apps/doodle/pkg/log" "git.kirsle.net/apps/doodle/pkg/modal/loadscreen" @@ -175,6 +176,12 @@ func (s *MainScene) Setup(d *Doodle) error { 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()) + } + }, }) } s.winLevelPacks.MoveTo(render.Point{ @@ -183,6 +190,7 @@ func (s *MainScene) Setup(d *Doodle) error { }) s.winLevelPacks.Show() }, + Style: &balance.ButtonBabyBlue, }, { Name: "Play a Level", diff --git a/pkg/play_scene.go b/pkg/play_scene.go index c1cb978..b5a2026 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -9,6 +9,7 @@ import ( "git.kirsle.net/apps/doodle/pkg/doodads" "git.kirsle.net/apps/doodle/pkg/keybind" "git.kirsle.net/apps/doodle/pkg/level" + "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/modal/loadscreen" @@ -30,6 +31,10 @@ type PlayScene struct { RememberScrollPosition render.Point // for the Editor quality of life SpawnPoint render.Point // if not zero, overrides Start Flag + // If this level was part of a levelpack. The Play Scene will read it + // from the levelpack ZIP file in priority over any other location. + LevelPack *levelpack.LevelPack + // Private variables. d *Doodle drawing *uix.Canvas @@ -374,6 +379,24 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { // Beaten the level? if success { config.OnRetryCheckpoint = nil + + // Are we in a levelpack? Show the "Next Level" button if there is + // a sequel to this level. + if s.LevelPack != nil { + for i, level := range s.LevelPack.Levels { + i := i + level := level + + if level.Filename == s.Filename && i < len(s.LevelPack.Levels)-1 { + // Show "Next" button! + config.OnNextLevel = func() { + nextLevel := s.LevelPack.Levels[i+1] + log.Info("Advance to next level: %s", nextLevel.Filename) + s.d.PlayFromLevelpack(*s.LevelPack, nextLevel) + } + } + } + } } // Show the modal. @@ -595,15 +618,45 @@ func (s *PlayScene) Drawing() *uix.Canvas { } // LoadLevel loads a level from disk. +// +// If the PlayScene was called with a LevelPack, it will check there +// first before the usual locations. +// +// The usual locations are: embedded bindata, ./assets folder on disk, +// and user content finally. func (s *PlayScene) LoadLevel(filename string) error { s.Filename = filename - level, err := level.LoadFile(filename) - if err != nil { - return fmt.Errorf("PlayScene.LoadLevel(%s): %s", filename, err) + var ( + lvl *level.Level + err error + ) + + // Are we playing out of a levelpack? + if s.LevelPack != nil { + levelbin, err := s.LevelPack.GetData("levels/" + filename) + if err != nil { + log.Error("Error reading levels/%s from zip: %s", filename, err) + } + + lvl, err = level.FromJSON(filename, levelbin) + if err != nil { + log.Error("PlayScene.LoadLevel(%s) from zipfile: %s", filename, err) + } + + log.Info("PlayScene.LoadLevel: found %s in LevelPack zip data", filename) } - s.Level = level + // Try the usual suspects. + if lvl == nil { + log.Info("PlayScene.LoadLevel: trying the usual places") + lvl, err = level.LoadFile(filename) + if err != nil { + return fmt.Errorf("PlayScene.LoadLevel(%s): %s", filename, err) + } + } + + s.Level = lvl s.drawing.LoadLevel(s.Level) s.drawing.InstallActors(s.Level.Actors) diff --git a/pkg/story_scene.go b/pkg/story_scene.go index fad2191..4852312 100644 --- a/pkg/story_scene.go +++ b/pkg/story_scene.go @@ -63,8 +63,6 @@ func (s *StoryScene) Setup(d *Doodle) error { s.levelSelectFrame = windows.NewLevelPackWindow(windows.LevelPack{ Supervisor: s.supervisor, Engine: d.Engine, - - OnPlayLevel: func(levelpack, filename string) {}, }) s.levelSelectFrame.Show() diff --git a/pkg/userdir/userdir.go b/pkg/userdir/userdir.go index 684b6ea..3f4bc92 100644 --- a/pkg/userdir/userdir.go +++ b/pkg/userdir/userdir.go @@ -28,8 +28,9 @@ var ( // File extensions const ( - extLevel = ".level" - extDoodad = ".doodad" + extLevel = ".level" + extDoodad = ".doodad" + extLevelPack = ".levelpack" ) func init() { @@ -69,6 +70,11 @@ func DoodadPath(filename string) string { return resolvePath(DoodadDirectory, filename, extDoodad) } +// LevelPackPath returns the user's levelpacks directory. +func LevelPackPath(filename string) string { + return resolvePath(LevelPackDirectory, filename, extLevelPack) +} + // CacheFilename returns a path to a file in the cache folder. Send in path // components and not literal slashes, like // CacheFilename("images", "chunks", "id.bmp") diff --git a/pkg/windows/levelpack_open.go b/pkg/windows/levelpack_open.go index 2bf8f8f..d0853ae 100644 --- a/pkg/windows/levelpack_open.go +++ b/pkg/windows/levelpack_open.go @@ -17,11 +17,11 @@ type LevelPack struct { Engine render.Engine // Callback functions. - OnPlayLevel func(levelpack, filename string) + OnPlayLevel func(pack levelpack.LevelPack, level levelpack.Level) // Internal variables - window *ui.Window - gotoIndex func() // return to index screen + window *ui.Window + tabFrame *ui.TabFrame } // NewLevelPackWindow initializes the window. @@ -35,6 +35,15 @@ func NewLevelPackWindow(config LevelPack) *ui.Window { height = 300 ) + // Get the available .levelpack files. + lpFiles, packmap, err := levelpack.LoadAllAvailable() + if err != nil { + log.Error("Couldn't list levelpack files: %s", err) + } + + log.Error("lpFiles: %+v", lpFiles) + log.Error("packmap: %+v", packmap) + window := ui.NewWindow(title) window.SetButtons(ui.CloseButton) window.Configure(ui.Config{ @@ -51,32 +60,53 @@ func NewLevelPackWindow(config LevelPack) *ui.Window { 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) { + // 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) + 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. - indexScreen.Hide() - screen.Show() - }) - window.Pack(indexScreen, ui.Pack{ - Side: ui.N, - Fill: true, - Expand: true, + log.Info("Called for tab: %s", screen) + 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]) + } + // indexTab.Resize(render.Rect{ + // W: width-4, + // H: height-4, + // }) + + tabFrame.Supervise(config.Supervisor) 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 { +/* 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 @@ -87,7 +117,6 @@ func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Fra perPage = 3 maxPageButtons = 10 ) - frame := ui.NewFrame("Index Screen") label := ui.NewLabel(ui.Label{ Text: "Select from a Level Pack below:", @@ -99,12 +128,6 @@ func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Fra 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), @@ -113,12 +136,12 @@ func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Fra 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) + filename := filename + lp, ok := packmap[filename] + if !ok { + log.Error("Couldn't find %s in packmap!", filename) continue } - _ = lp // Make a frame to hold a complex button layout. btnFrame := ui.NewFrame("Frame") @@ -157,19 +180,9 @@ func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Fra 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) + onChoose(filename) return nil }) @@ -179,7 +192,7 @@ func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Fra }) config.Supervisor.Add(button) - if i > perPage { + if i > perPage-1 { button.Hide() } buttons = append(buttons, button) @@ -189,6 +202,170 @@ func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Fra 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 = 3 + pages = int( + math.Ceil( + float64(len(lp.Levels)) / float64(perPage), + ), + ) + maxPageButtons = 10 + ) + + /** 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 + + // Make a frame to hold a complex button layout. + btnFrame := ui.NewFrame("Frame") + btnFrame.Resize(render.Rect{ + W: buttonWidth, + H: buttonHeight, + }) + + title := ui.NewLabel(ui.Label{ + Text: level.Title, + Font: balance.LabelFont, + }) + btnFrame.Pack(title, ui.Pack{ + Side: ui.NW, + }) + + btn := ui.NewButton(level.Filename, btnFrame) + btn.Handle(ui.Click, func(ed ui.EventData) error { + // 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) { @@ -224,35 +401,3 @@ func (config LevelPack) makeIndexScreen(width, height int, onChoose func(*ui.Fra 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 -}