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.
This commit is contained in:
Noah 2021-12-26 20:48:29 -08:00
parent 678326540b
commit 6d3ffcd98c
10 changed files with 383 additions and 84 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ wasm/assets/
*.wasm
*.doodad
*.level
*.levelpack
docker/ubuntu
docker/debian
docker/fedora

View File

@ -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/")

View File

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

View File

@ -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")
}

View File

@ -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.

View File

@ -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",

View File

@ -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)

View File

@ -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()

View File

@ -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")

View File

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