doodle/pkg/levelpack/levelpack.go
Noah Petherbridge 6d3ffcd98c 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.
2021-12-26 20:48:29 -08:00

198 lines
4.5 KiB
Go

// Package levelpack handles ZIP archives for level packs.
package levelpack
import (
"archive/zip"
"encoding/json"
"errors"
"io/ioutil"
"os"
"runtime"
"sort"
"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.
type LevelPack struct {
Title string `json:"title`
Description string `json:"description"`
Author string `json:"author"`
Created time.Time `json:"created"`
// Cached metadata about the (displayed) levels.
Levels []Level `json:"levels"`
// Number of levels unlocked by default.
// 0 = all levels unlocked
FreeLevels int `json:"freeLevels"`
// The loaded zip file for reading an existing levelpack.
Zipfile *zip.Reader `json:"-"`
}
// Level holds metadata about the levels in the levelpack.
type Level struct {
Title string `json:"title"`
Author string `json:"author"`
Filename string `json:"filename"`
}
// LoadFile reads a .levelpack zip file.
func LoadFile(filename string) (LevelPack, error) {
stat, err := os.Stat(filename)
if err != nil {
return LevelPack{}, err
}
fh, err := os.Open(filename)
if err != nil {
return LevelPack{}, err
}
reader, err := zip.NewReader(fh, stat.Size())
if err != nil {
return LevelPack{}, err
}
lp := LevelPack{
Zipfile: reader,
}
// Read the index.json.
lp.GetJSON(&lp, "index.json")
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) {
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)
}
}
// 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.
func (l LevelPack) WriteFile(filename string) error {
out, err := json.Marshal(l)
if err != nil {
return err
}
return ioutil.WriteFile(filename, out, 0655)
}
// GetData returns file data from inside the loaded zipfile of a levelpack.
func (l LevelPack) GetData(filename string) ([]byte, error) {
if l.Zipfile == nil {
return []byte{}, errors.New("zipfile not loaded")
}
file, err := l.Zipfile.Open(filename)
if err != nil {
return []byte{}, err
}
return ioutil.ReadAll(file)
}
// GetJSON loads a JSON file from the zipfile and marshals it into your struct.
func (l LevelPack) GetJSON(v interface{}, filename string) error {
data, err := l.GetData(filename)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// ListFiles returns the files in the zipfile that match the prefix given.
func (l LevelPack) ListFiles(prefix string) []string {
var result []string
if l.Zipfile != nil {
for _, file := range l.Zipfile.File {
if strings.HasPrefix(file.Name, prefix) {
result = append(result, file.Name)
}
}
}
return result
}