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 -}